diff --git a/src/App.tsx b/src/App.tsx index 3acd3833e9..4e2a003aa7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import Acls from "./components/users/Acls"; import About from "./components/About"; import { useAppDispatch } from "./store"; import { fetchOcVersion, fetchUserInfo } from "./slices/userInfoSlice"; +import LifeCyclePolicies from "./components/events/LifeCyclePolicies"; function App() { const dispatch = useAppDispatch(); @@ -41,6 +42,8 @@ function App() { } /> + } /> + } /> } /> diff --git a/src/components/events/LifeCyclePolicies.tsx b/src/components/events/LifeCyclePolicies.tsx new file mode 100644 index 0000000000..f61c9e1d7d --- /dev/null +++ b/src/components/events/LifeCyclePolicies.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from "react"; +import MainNav from "../shared/MainNav"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import cn from "classnames"; +import TableFilters from "../shared/TableFilters"; +import Table from "../shared/Table"; +import Notifications from "../shared/Notifications"; +import { loadEventsIntoTable, loadLifeCyclePoliciesIntoTable, loadSeriesIntoTable } from "../../thunks/tableThunks"; +import { fetchFilters, editTextFilter, fetchStats } from "../../slices/tableFilterSlice"; +import Header from "../Header"; +import NavBar from "../NavBar"; +import MainView from "../MainView"; +import Footer from "../Footer"; +import { getUserInformation } from "../../selectors/userInfoSelectors"; +import { hasAccess } from "../../utils/utils"; +import { getCurrentFilterResource } from "../../selectors/tableFilterSelectors"; +import { useAppDispatch, useAppSelector } from "../../store"; +import { AsyncThunk } from "@reduxjs/toolkit"; +import { getTotalLifeCyclePolicies } from "../../selectors/lifeCycleSelectors"; +import { fetchLifeCyclePolicies } from "../../slices/lifeCycleSlice"; +import { lifeCyclePoliciesTemplateMap } from "../../configs/tableConfigs/lifeCyclePoliciesTableMap"; +import { fetchEvents } from "../../slices/eventSlice"; +import { setOffset } from "../../slices/tableSlice"; +import { fetchSeries } from "../../slices/seriesSlice"; + +/** + * This component renders the table view of policies + */ +const LifeCyclePolicies = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [displayNavigation, setNavigation] = useState(false); + + const user = useAppSelector(state => getUserInformation(state)); + const policiesTotal = useAppSelector(state => getTotalLifeCyclePolicies(state)); + const currentFilterType = useAppSelector(state => getCurrentFilterResource(state)); + + const loadEvents = async () => { + // Fetching stats from server + dispatch(fetchStats()); + + // Fetching events from server + await dispatch(fetchEvents()); + + // Load events into table + dispatch(loadEventsIntoTable()); + }; + + const loadSeries = () => { + // Reset the current page to first page + dispatch(setOffset(0)); + + //fetching series from server + dispatch(fetchSeries()); + + //load series into table + dispatch(loadSeriesIntoTable()); + }; + + const loadLifeCyclePolicies = async () => { + // Fetching policies from server + await dispatch(fetchLifeCyclePolicies()); + + // Load policies into table + dispatch(loadLifeCyclePoliciesIntoTable()); + }; + + useEffect(() => { + if ("lifeCyclePolicies" !== currentFilterType) { + dispatch(fetchFilters("lifeCyclePolicies")); + } + + // Reset text filter + dispatch(editTextFilter("")); + + // Load policies on mount + loadLifeCyclePolicies().then((r) => console.info(r)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toggleNavigation = () => { + setNavigation(!displayNavigation); + }; + + return ( + <> +
+ + {/* Include Burger-button menu*/} + + + + + + + {/* Include notifications component */} + + +
+ {/* Include filters component */} + {/* LifeCycle policies are not indexed, can't search or filter them */} + {/* But if we don't include this component, the policies won't load on page load, because the first + fetch request we send to the backend contains invalid params >.> */} + } + loadResourceIntoTable={loadLifeCyclePoliciesIntoTable} + resource={"lifeCyclePolicies"} + /> + +

{t("LIFECYCLE.POLICIES.TABLE.CAPTION")}

+

{t("TABLE_SUMMARY", { numberOfRows: policiesTotal })}

+
+ {/* Include table component */} + + +
+ + ); +}; + +export default LifeCyclePolicies; diff --git a/src/components/events/partials/EventsNavigation.tsx b/src/components/events/partials/EventsNavigation.tsx index 5185035005..97577e53a8 100644 --- a/src/components/events/partials/EventsNavigation.tsx +++ b/src/components/events/partials/EventsNavigation.tsx @@ -1,8 +1,9 @@ import { fetchEvents } from "../../../slices/eventSlice"; +import { fetchLifeCyclePolicies } from "../../../slices/lifeCycleSlice"; import { fetchSeries } from "../../../slices/seriesSlice"; import { fetchStats } from "../../../slices/tableFilterSlice"; import { AppDispatch } from "../../../store"; -import { loadEventsIntoTable, loadSeriesIntoTable } from "../../../thunks/tableThunks"; +import { loadEventsIntoTable, loadLifeCyclePoliciesIntoTable, loadSeriesIntoTable } from "../../../thunks/tableThunks"; /** * Utility file for the navigation bar @@ -27,6 +28,14 @@ export const loadSeries = (dispatch: AppDispatch) => { dispatch(loadSeriesIntoTable()); }; +export const loadLifeCyclePolicies = (dispatch: AppDispatch) => { + // Fetching policies from server + dispatch(fetchLifeCyclePolicies()); + + // Load policies into table + dispatch(loadLifeCyclePoliciesIntoTable()); +}; + export const eventsLinks = [ { path: "/events/events", @@ -39,5 +48,11 @@ export const eventsLinks = [ accessRole: "ROLE_UI_SERIES_VIEW", loadFn: loadSeries, text: "EVENTS.EVENTS.NAVIGATION.SERIES" + }, + { + path: "/events/lifeCyclePolicies", + accessRole: "ROLE_UI_LIFECYCLEPOLICIES_VIEW", + loadFn: loadLifeCyclePolicies, + text: "LIFECYCLE.NAVIGATION.POLICIES" } ]; diff --git a/src/components/events/partials/LifeCyclePolicyActionCell.tsx b/src/components/events/partials/LifeCyclePolicyActionCell.tsx new file mode 100644 index 0000000000..ce5df1e317 --- /dev/null +++ b/src/components/events/partials/LifeCyclePolicyActionCell.tsx @@ -0,0 +1,62 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppDispatch, useAppSelector } from "../../../store"; +import { Tooltip } from "../../shared/Tooltip"; +import { LifeCyclePolicy } from "../../../slices/lifeCycleSlice"; +import DetailsModal from "../../shared/modals/DetailsModal"; +import LifeCyclePolicyDetails from "./modals/LifeCyclePolicyDetails"; +import { hasAccess } from "../../../utils/utils"; +import { getUserInformation } from "../../../selectors/userInfoSelectors"; +import { fetchLifeCyclePolicyDetails } from "../../../slices/lifeCycleDetailsSlice"; + +/** + * This component renders the title cells of series in the table view + */ +const LifeCyclePolicyActionCell = ({ + row, +}: { + row: LifeCyclePolicy +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const [displayLifeCyclePolicyDetails, setLifeCyclePolicyDetails] = useState(false); + + const user = useAppSelector(state => getUserInformation(state)); + + const showLifeCyclePolicyDetails = async () => { + await dispatch(fetchLifeCyclePolicyDetails(row.id)); + + setLifeCyclePolicyDetails(true); + }; + + const hideLifeCyclePolicyDetails = () => { + setLifeCyclePolicyDetails(false); + }; + + return ( + <> + {/* view details location/recording */} + {hasAccess("ROLE_UI_LIFECYCLEPOLICY_DETAILS_VIEW", user) && ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.TITLE")}{policy.title}
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.ISACTIVE")}{ + + }
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.ISCREATEDFROMCONFIG")}{ + + }
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.TARGETTYPE")}{t(policy.targetType)}
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.TARGETFILTERS")} + + + {Object.entries(policy.targetFilters).map(([key, value], index) => { + return( + + + + + ) + })} + +
{key}{value.value + ", " + value.type + ", " + value.must}
+
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.TIMING")}{t(policy.timing)}
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.ACTIONDATE")}{t("dateFormats.dateTime.full", { dateTime: renderValidDate(policy.actionDate) })}
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.CRONTRIGGER")}{policy.cronTrigger}
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.ACTION")}{t(policy.action)}
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.ACTIONPARAMETERS")} + + + {Object.entries(policy.actionParameters).map(([key, value], index) => { + return( + + + + + ) + })} + +
{key}{value ?? ""}
+
{t("LIFECYCLE.POLICIES.DETAILS.GENERAL.ID")}{policy.id}
+ + + + + + ); +}; + +export default LifeCyclePolicyGeneralTab; diff --git a/src/components/events/partials/modals/LifeCyclePolicyDetails.tsx b/src/components/events/partials/modals/LifeCyclePolicyDetails.tsx new file mode 100644 index 0000000000..e818dd57c2 --- /dev/null +++ b/src/components/events/partials/modals/LifeCyclePolicyDetails.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import ModalNavigation from "../../../shared/modals/ModalNavigation"; +import { getLifeCyclePolicyDetails } from "../../../../selectors/lifeCycleDetailsSelectors"; +import LifeCyclePolicyGeneralTab from "../ModalTabsAndPages/LifeCyclePolicyGeneralTab"; +import LifeCyclePolicyDetailsAccessTab from "../ModalTabsAndPages/LifeCyclePolicyAccessTab"; +import { useAppSelector } from "../../../../store"; + +/** + * This component manages the tabs of the series details modal + */ +const LifeCyclePolicyDetails = () => { + const [page, setPage] = useState(0); + + const policy = useAppSelector(state => getLifeCyclePolicyDetails(state)); + + // information about tabs + const tabs = [ + { + tabTranslation: "LIFECYCLE.POLICIES.DETAILS.TAB.GENERAL", + accessRole: "ROLE_UI_LIFECYCLEPOLICIES_DETAILS_GENERAL_VIEW", + name: "general", + }, + { + tabTranslation: "LIFECYCLE.POLICIES.DETAILS.TAB.ACCESSPOLICIES", + accessRole: "ROLE_UI_LIFECYCLEPOLICIES_DETAILS_ACCESSPOLICIES_VIEW", + name: "WARNING: None of the changes you make here can be saved!", + }, + ]; + + const openTab = (tabNr: number) => { + setPage(tabNr); + }; + + return ( + <> + {/* Navigation */} + + +
+ {page === 0 && } + {page === 1 && + false} + /> + } +
+ + ); +}; + +export default LifeCyclePolicyDetails; diff --git a/src/components/shared/MainNav.tsx b/src/components/shared/MainNav.tsx index 2207141cd8..88110f03a7 100644 --- a/src/components/shared/MainNav.tsx +++ b/src/components/shared/MainNav.tsx @@ -6,6 +6,7 @@ import { loadEventsIntoTable, loadGroupsIntoTable, loadJobsIntoTable, + loadLifeCyclePoliciesIntoTable, loadRecordingsIntoTable, loadSeriesIntoTable, loadServersIntoTable, @@ -34,6 +35,7 @@ import { fetchSeries } from "../../slices/seriesSlice"; import { fetchJobs } from "../../slices/jobSlice"; import { fetchEvents } from "../../slices/eventSlice"; import { Tooltip } from "./Tooltip"; +import { fetchLifeCyclePolicies } from "../../slices/lifeCycleSlice"; /** * This component renders the main navigation that opens when the burger button is clicked @@ -84,6 +86,19 @@ const MainNav = ({ dispatch(loadSeriesIntoTable()); }; + const loadLifeCyclePolicies = () => { + dispatch(fetchFilters("lifeCylePolicies")); + + // Reset the current page to first page + dispatch(setOffset(0)); + + // Fetching lifeCycle policies from server + dispatch(fetchLifeCyclePolicies()); + + // Load lifeCycle policies into table + dispatch(loadLifeCyclePoliciesIntoTable()); + }; + const loadRecordings = () => { dispatch(fetchFilters("recordings")); @@ -228,13 +243,19 @@ const MainNav = ({ - ) : ( - hasAccess("ROLE_UI_SERIES_VIEW", user) && ( + ) : hasAccess("ROLE_UI_SERIES_VIEW", user) ? ( loadSeries()}> + ) : ( + hasAccess("ROLE_UI_LIFECYCLEPOLICIES_VIEW", user) && ( + loadLifeCyclePolicies()}> + + + + ) ))} {hasAccess("ROLE_UI_NAV_CAPTURE_VIEW", user) && diff --git a/src/components/shared/modals/DetailsModal.tsx b/src/components/shared/modals/DetailsModal.tsx index 6e13ff755d..5ebd58ec5a 100644 --- a/src/components/shared/modals/DetailsModal.tsx +++ b/src/components/shared/modals/DetailsModal.tsx @@ -45,4 +45,4 @@ const DetailsModal = ({ ); }; -export default DetailsModal; \ No newline at end of file +export default DetailsModal; diff --git a/src/configs/tableConfigs/lifeCyclePoliciesTableConfig.ts b/src/configs/tableConfigs/lifeCyclePoliciesTableConfig.ts new file mode 100644 index 0000000000..71a47c8a8e --- /dev/null +++ b/src/configs/tableConfigs/lifeCyclePoliciesTableConfig.ts @@ -0,0 +1,30 @@ +import { TableConfig } from "./aclsTableConfig"; + +export const lifeCyclePolicyTableConfig: TableConfig = { + columns: [ + { + name: "title", + label: "LIFECYCLE.POLICIES.TABLE.TITLE", + sortable: true, + }, + { + template: "LifeCyclePolicyIsActiveCell", + name: "isActive", + label: "LIFECYCLE.POLICIES.TABLE.ISACTIVE", + }, + { + name: "timing", + label: "LIFECYCLE.POLICIES.TABLE.TIMING", + sortable: true, + }, + { + template: "LifeCyclePolicyActionCell", + name: "actions", + label: "LIFECYCLE.POLICIES.TABLE.ACTION", + }, + ], + caption: "TABLE.CAPTION", + resource: "lifeCyclePolicies", + category: "events", + multiSelect: false, +}; diff --git a/src/configs/tableConfigs/lifeCyclePoliciesTableMap.ts b/src/configs/tableConfigs/lifeCyclePoliciesTableMap.ts new file mode 100644 index 0000000000..7a460a6363 --- /dev/null +++ b/src/configs/tableConfigs/lifeCyclePoliciesTableMap.ts @@ -0,0 +1,11 @@ +import LifeCyclePolicyActionCell from "../../components/events/partials/LifeCyclePolicyActionCell"; +import LifeCyclePolicyIsActiveCell from "../../components/events/partials/LifeCyclePolicyIsActiveCell"; + +/** + * This map contains the mapping between the template strings above and the corresponding react component. + * This helps to render different templates of cells more dynamically + */ +export const lifeCyclePoliciesTemplateMap = { + LifeCyclePolicyIsActiveCell: LifeCyclePolicyIsActiveCell, + LifeCyclePolicyActionCell: LifeCyclePolicyActionCell, +}; diff --git a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json index 046922106e..6a70a57394 100644 --- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json +++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json @@ -1255,6 +1255,48 @@ } } }, + "LIFECYCLE": { + "NAVIGATION": { + "POLICIES": "LifeCycle Policies" + }, + "POLICIES": { + "TABLE": { + "ACTION": "Actions", + "CAPTION": "LifeCycle Policies", + "ISACTIVE": "Active", + "TIMING": "Timing", + "TITLE": "Title", + "TOOLTIP": { + "DETAILS": "LifeCycle Policy Details" + } + }, + "DETAILS": { + "HEADER": "LifeCycle Policy Details", + "GENERAL": { + "ACTION": "Action", + "ACTIONDATE": "Action Date", + "ACTIONPARAMETERS": "Action Parameters", + "CAPTION": "LifeCycle Policy Details", + "CRONTRIGGER": "Cron Trigger", + "ID": "Identifier", + "ISACTIVE": "Active", + "ISCREATEDFROMCONFIG": "Created from Config", + "TARGETTYPE": "Target Type", + "TARGETFILTERS": "Target Filters", + "TIMING": "Timing", + "TITLE": "Title" + }, + "ACCESS": { + "LABEL": "Select a template", + "DESCRIPTION": "At least one role with Read and Write permissions is required." + }, + "TAB": { + "GENERAL": "General", + "ACCESSPOLICIES": "Access Policies" + } + } + } + }, "RECORDINGS": { "NAVIGATION": { "LOCATIONS": "Locations" diff --git a/src/selectors/lifeCycleDetailsSelectors.ts b/src/selectors/lifeCycleDetailsSelectors.ts new file mode 100644 index 0000000000..364368819a --- /dev/null +++ b/src/selectors/lifeCycleDetailsSelectors.ts @@ -0,0 +1,7 @@ +import { RootState } from "../store"; + +/** + * This file contains selectors regarding details of a certain lifeCyclePolicy/capture agent + */ +export const getLifeCyclePolicyDetails = (state: RootState) => state.lifeCyclePolicyDetails; +export const getLifeCyclePolicyDetailsAcl = (state: RootState) => state.lifeCyclePolicyDetails.accessControlEntries; diff --git a/src/selectors/lifeCycleSelectors.ts b/src/selectors/lifeCycleSelectors.ts new file mode 100644 index 0000000000..fadb641e3d --- /dev/null +++ b/src/selectors/lifeCycleSelectors.ts @@ -0,0 +1,7 @@ +import { RootState } from "../store"; + +/** + * This file contains selectors regarding acls + */ +export const getLifeCyclePolicies = (state: RootState) => state.lifeCycle.results; +export const getTotalLifeCyclePolicies = (state: RootState) => state.lifeCycle.total; diff --git a/src/slices/lifeCycleDetailsSlice.ts b/src/slices/lifeCycleDetailsSlice.ts new file mode 100644 index 0000000000..2bba3cda56 --- /dev/null +++ b/src/slices/lifeCycleDetailsSlice.ts @@ -0,0 +1,136 @@ +import { PayloadAction, SerializedError, createSlice } from '@reduxjs/toolkit' +import axios from 'axios'; +import { createAppAsyncThunk } from '../createAsyncThunkWithTypes'; +import { LifeCyclePolicy } from './lifeCycleSlice'; +import { TransformedAcl } from './aclDetailsSlice'; +import { createPolicy } from '../utils/resourceUtils'; +import { Ace } from './aclSlice'; + +/** + * This file contains redux reducer for actions affecting the state of a lifeCyclePolicy/capture agent + */ +interface LifeCyclePolicyDetailsState extends LifeCyclePolicy { + statusLifeCyclePolicyDetails: 'uninitialized' | 'loading' | 'succeeded' | 'failed', + errorLifeCyclePolicyDetails: SerializedError | null, +} + +// Initial state of lifeCyclePolicy details in redux store +const initialState: LifeCyclePolicyDetailsState = { + statusLifeCyclePolicyDetails: 'uninitialized', + errorLifeCyclePolicyDetails: null, + actionParameters: {}, + timing: "SPECIFIC_DATE", + action: "START_WORKFLOW", + targetType: "EVENT", + id: "", + title: "", + isActive: false, + isCreatedFromConfig: false, + actionDate: "", + cronTrigger: "", + targetFilters: {}, + accessControlEntries: [] +}; + +// fetch details of certain lifeCyclePolicy from server +export const fetchLifeCyclePolicyDetails = createAppAsyncThunk('lifeCyclePolicyDetails/fetchLifeCyclePolicyDetails', async (id: string) => { + const res = await axios.get(`/api/lifecyclemanagement/policies/${id}`); + const data = res.data; + + data.actionParameters = JSON.parse(data.actionParameters) + data.targetFilters = JSON.parse(data.targetFilters) + + let accessPolicies : { + id: number, + allow: boolean, + role: string, + action: string, + }[] = data.accessControlEntries; + let acls: TransformedAcl[] = []; + + const json = accessPolicies; + let newPolicies: { [key: string]: TransformedAcl } = {}; + let policyRoles: string[] = []; + for (let i = 0; i < json.length; i++) { + const policy: Ace = json[i]; + if (!newPolicies[policy.role]) { + newPolicies[policy.role] = createPolicy(policy.role); + policyRoles.push(policy.role); + } + if (policy.action === "read" || policy.action === "write") { + newPolicies[policy.role][policy.action] = policy.allow; + } else if (policy.allow === true) { //|| policy.allow === "true") { + newPolicies[policy.role].actions.push(policy.action); + } + } + acls = policyRoles.map((role) => newPolicies[role]); + + data.accessControlEntries = acls; + + return data; +}); + +// Dummy function for compatability +export const fetchLifeCyclePolicyDetailsAcls = createAppAsyncThunk('lifeCyclePolicyDetails/fetchLifeCyclePolicyDetailsAcls', async (id: string, {getState}) => { + const state = getState(); + return state.lifeCyclePolicyDetails.accessControlEntries; +}); + +// Dummy function for compatability +export const updateLifeCyclePolicyAccess = createAppAsyncThunk('lifeCyclePolicyDetails/fetchLifeCyclePolicyDetailsAcls', async (params: { + id: string, + policies: { acl: { ace: Ace[] } } +}, {dispatch}) => { + return false; +}); + +const lifeCyclePolicyDetailsSlice = createSlice({ + name: 'lifeCyclePolicyDetails', + initialState, + reducers: {}, + // These are used for thunks + extraReducers: builder => { + builder + .addCase(fetchLifeCyclePolicyDetails.pending, (state) => { + state.statusLifeCyclePolicyDetails = 'loading'; + }) + .addCase(fetchLifeCyclePolicyDetails.fulfilled, (state, action: PayloadAction<{ + actionParameters: LifeCyclePolicyDetailsState["actionParameters"], + timing: LifeCyclePolicyDetailsState["timing"], + action: LifeCyclePolicyDetailsState["action"], + targetType: LifeCyclePolicyDetailsState["targetType"], + id: LifeCyclePolicyDetailsState["id"], + title: LifeCyclePolicyDetailsState["title"], + isActive: LifeCyclePolicyDetailsState["isActive"], + isCreatedFromConfig: LifeCyclePolicyDetailsState["isCreatedFromConfig"], + actionDate: LifeCyclePolicyDetailsState["actionDate"], + cronTrigger: LifeCyclePolicyDetailsState["cronTrigger"], + targetFilters: LifeCyclePolicyDetailsState["targetFilters"], + accessControlEntries: LifeCyclePolicyDetailsState["accessControlEntries"], + }>) => { + state.statusLifeCyclePolicyDetails = 'succeeded'; + const lifeCyclePolicyDetails = action.payload; + state.actionParameters = lifeCyclePolicyDetails.actionParameters; + state.timing = lifeCyclePolicyDetails.timing; + state.action = lifeCyclePolicyDetails.action; + state.targetType = lifeCyclePolicyDetails.targetType; + state.id = lifeCyclePolicyDetails.id; + state.title = lifeCyclePolicyDetails.title; + state.isActive = lifeCyclePolicyDetails.isActive; + state.isCreatedFromConfig = lifeCyclePolicyDetails.isCreatedFromConfig; + state.actionDate = lifeCyclePolicyDetails.actionDate; + state.cronTrigger = lifeCyclePolicyDetails.cronTrigger; + state.targetFilters = lifeCyclePolicyDetails.targetFilters; + state.accessControlEntries = lifeCyclePolicyDetails.accessControlEntries; + }) + .addCase(fetchLifeCyclePolicyDetails.rejected, (state, action) => { + state.statusLifeCyclePolicyDetails = 'failed'; + state.errorLifeCyclePolicyDetails = action.error; + }); + } +}); + +// export const {} = lifeCyclePolicyDetailsSlice.actions; + +// Export the slice reducer as the default export +export default lifeCyclePolicyDetailsSlice.reducer; diff --git a/src/slices/lifeCycleSlice.ts b/src/slices/lifeCycleSlice.ts new file mode 100644 index 0000000000..cfa4e78192 --- /dev/null +++ b/src/slices/lifeCycleSlice.ts @@ -0,0 +1,112 @@ +import { PayloadAction, SerializedError, createSlice } from '@reduxjs/toolkit' +import { TableConfig } from "../configs/tableConfigs/aclsTableConfig"; +import { lifeCyclePolicyTableConfig } from "../configs/tableConfigs/lifeCyclePoliciesTableConfig"; +import axios from 'axios'; +import { getURLParams } from '../utils/resourceUtils'; +import { createAppAsyncThunk } from '../createAsyncThunkWithTypes'; +import { TransformedAcl } from './aclDetailsSlice'; + +type LifeCyclePolicyTiming = "SPECIFIC_DATE" | "REPEATING" | "ALWAYS"; +type LifeCyclePolicyAction = "START_WORKFLOW" +type LifeCyclePolicyTargetType = "EVENT" + +export type LifeCyclePolicy = { + actionParameters: { [key: string]: unknown }, // JSON. Variable, depends on action + timing: LifeCyclePolicyTiming, + action: LifeCyclePolicyAction, + targetType: LifeCyclePolicyTargetType, + id: string, + title: string, + isActive: boolean, + isCreatedFromConfig: boolean, + actionDate: string, // Date + cronTrigger: string, + targetFilters: { [key: string]: { + value: string, + type: "SEARCH" | "WILDCARD" | "GREATER_THAN" | "LESS_THAN", + must: boolean + }}, + accessControlEntries: TransformedAcl[] +} + +type LifeCycleState = { + status: 'uninitialized' | 'loading' | 'succeeded' | 'failed', + error: SerializedError | null, + results: LifeCyclePolicy[], + columns: TableConfig["columns"], + total: number, + count: number, + offset: number, + limit: number, +}; + +// Fill columns initially with columns defined in aclsTableConfig +const initialColumns = lifeCyclePolicyTableConfig.columns.map((column) => ({ + ...column, + deactivated: false, +})); + +// Initial state of acls in redux store +const initialState: LifeCycleState = { + status: 'uninitialized', + error: null, + results: [], + columns: initialColumns, + total: 0, + count: 0, + offset: 0, + limit: 0, +}; + +export const fetchLifeCyclePolicies = createAppAsyncThunk('lifeCycle/fetchLifeCyclePolicies', async (_, { getState }) => { + const state = getState(); + let params = getURLParams(state); + const res = await axios.get("/api/lifecyclemanagement/policies", { params: params }); + return res.data; +}); + +const lifeCycleSlice = createSlice({ + name: 'lifeCycle', + initialState, + reducers: { + setLifeCycleColumns(state, action: PayloadAction< + LifeCycleState["columns"] + >) { + state.columns = action.payload; + }, + }, + // These are used for thunks + extraReducers: builder => { + builder + .addCase(fetchLifeCyclePolicies.pending, (state) => { + state.status = 'loading'; + }) + // Pass the generated action creators to `.addCase()` + .addCase(fetchLifeCyclePolicies.fulfilled, (state, action: PayloadAction<{ + total: LifeCycleState["total"], + count: LifeCycleState["count"], + limit: LifeCycleState["limit"], + offset: LifeCycleState["offset"], + results: LifeCycleState["results"], + }>) => { + // Same "mutating" update syntax thanks to Immer + state.status = 'succeeded'; + const policies = action.payload; + state.total = policies.total; + state.count = policies.count; + state.limit = policies.limit; + state.offset = policies.offset; + state.results = policies.results; + }) + .addCase(fetchLifeCyclePolicies.rejected, (state, action) => { + state.status = 'failed'; + state.results = []; + state.error = action.error; + }); + } +}); + +export const { setLifeCycleColumns } = lifeCycleSlice.actions; + +// Export the slice reducer as the default export +export default lifeCycleSlice.reducer; diff --git a/src/slices/tableSlice.ts b/src/slices/tableSlice.ts index 1e2595bfbe..5d052940b3 100644 --- a/src/slices/tableSlice.ts +++ b/src/slices/tableSlice.ts @@ -10,6 +10,7 @@ import { AclResult } from './aclSlice'; import { ThemeDetailsType } from './themeSlice'; import { Series } from './seriesSlice'; import { Event } from './eventSlice'; +import { LifeCyclePolicy } from './lifeCycleSlice'; /* Overview of the structure of the data in arrays in state @@ -59,16 +60,16 @@ export function isRowSelectable(row: Row) { return false; } -export function isEvent(row: Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType): row is Event { +export function isEvent(row: Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType | LifeCyclePolicy): row is Event { return (row as Event).event_status !== undefined; } -export function isSeries(row: Row | Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType): row is Series { +export function isSeries(row: Row | Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType | LifeCyclePolicy): row is Series { return (row as Series).organizers !== undefined; } // TODO: Improve row typing. While this somewhat correctly reflects the current state of our code, it is rather annoying to work with. -export type Row = { selected: boolean } & ( Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType ) +export type Row = { selected: boolean } & ( Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType | LifeCyclePolicy) type TableState = { status: 'uninitialized' | 'loading' | 'succeeded' | 'failed', diff --git a/src/store.ts b/src/store.ts index d6973a7233..1a6175c879 100644 --- a/src/store.ts +++ b/src/store.ts @@ -6,6 +6,7 @@ import tableFilterProfiles from "./slices/tableFilterProfilesSlice"; import events from "./slices/eventSlice"; import table from "./slices/tableSlice"; import series from "./slices/seriesSlice"; +import lifeCycle from "./slices/lifeCycleSlice"; import recordings from "./slices/recordingSlice"; import jobs from "./slices/jobSlice"; import servers from "./slices/serverSlice"; @@ -19,6 +20,7 @@ import notifications from "./slices/notificationSlice"; import workflows from "./slices/workflowSlice"; import eventDetails from "./slices/eventDetailsSlice"; import seriesDetails from "./slices/seriesDetailsSlice"; +import lifeCyclePolicyDetails from "./slices/lifeCycleDetailsSlice"; import userDetails from "./slices/userDetailsSlice"; import recordingDetails from "./slices/recordingDetailsSlice"; import groupDetails from "./slices/groupDetailsSlice"; @@ -38,6 +40,7 @@ import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2"; const tableFilterProfilesPersistConfig = { key: "tableFilterProfiles", storage, whitelist: ["profiles"] } const eventsPersistConfig = { key: "events", storage, whitelist: ["columns"] } const seriesPersistConfig = { key: "series", storage, whitelist: ["columns"] } +const lifeCyclePersistConfig = { key: "lifeCycle", storage, whitelist: ["columns"] } const tablePersistConfig = { key: "table", storage, whitelist: ["pagination"] } const recordingsPersistConfig = { key: "recordings", storage, whitelist: ["columns"] } const jobsPersistConfig = { key: "jobs", storage, whitelist: ["columns"] } @@ -54,6 +57,7 @@ const reducers = combineReducers({ tableFilterProfiles: persistReducer(tableFilterProfilesPersistConfig, tableFilterProfiles), events: persistReducer(eventsPersistConfig, events), series: persistReducer(seriesPersistConfig, series), + lifeCycle: persistReducer(lifeCyclePersistConfig, lifeCycle), table: persistReducer(tablePersistConfig, table), recordings: persistReducer(recordingsPersistConfig, recordings), jobs: persistReducer(jobsPersistConfig, jobs), @@ -69,6 +73,7 @@ const reducers = combineReducers({ eventDetails, themeDetails, seriesDetails, + lifeCyclePolicyDetails, recordingDetails, userDetails, groupDetails, diff --git a/src/thunks/tableThunks.ts b/src/thunks/tableThunks.ts index 875e774f0a..723386b5b9 100644 --- a/src/thunks/tableThunks.ts +++ b/src/thunks/tableThunks.ts @@ -44,6 +44,8 @@ import { fetchRecordings, setRecordingsColumns } from "../slices/recordingSlice" import { setGroupColumns } from "../slices/groupSlice"; import { fetchAcls, setAclColumns } from "../slices/aclSlice"; import { AppDispatch, AppThunk, RootState } from "../store"; +import { lifeCyclePolicyTableConfig } from "../configs/tableConfigs/lifeCyclePoliciesTableConfig"; +import { fetchLifeCyclePolicies, setLifeCycleColumns } from "../slices/lifeCycleSlice"; /** * This file contains methods/thunks used to manage the table in the main view and its state changes @@ -147,6 +149,40 @@ export const loadSeriesIntoTable = (): AppThunk => (dispatch, getState) => { dispatch(loadResourceIntoTable(tableData)); }; +export const loadLifeCyclePoliciesIntoTable = (): AppThunk => (dispatch, getState) => { + const { lifeCycle, table } = getState() as RootState; + const pagination = table.pagination; + const resource = lifeCycle.results; + const total = lifeCycle.total; + + const pages = calculatePages(total / pagination.limit, pagination.offset); + + let tableData = { + resource: "lifeCyclePolicies", + rows: resource.map((obj) => { + return { ...obj, selected: false } + }), + columns: lifeCycle.columns, + multiSelect: table.multiSelect, + pages: pages, + sortBy: table.sortBy, + reverse: table.reverse, + totalItems: total, + }; + + if (table.resource !== "lifeCyclePolicies") { + const multiSelect = lifeCyclePolicyTableConfig.multiSelect; + + tableData = { + ...tableData, + sortBy: "title", + reverse: "ASC", + multiSelect: multiSelect, + }; + } + dispatch(loadResourceIntoTable(tableData)); +} + export const loadRecordingsIntoTable = (): AppThunk => (dispatch, getState) => { const { recordings, table } = getState() as RootState; const pagination = table.pagination; @@ -446,6 +482,11 @@ export const goToPage = (pageNumber: number) => async (dispatch: AppDispatch, ge dispatch(loadSeriesIntoTable()); break; } + case "lifeCyclePolicies": { + await dispatch(fetchLifeCyclePolicies()); + dispatch(loadLifeCyclePoliciesIntoTable()); + break; + } case "recordings": { await dispatch(fetchRecordings()); dispatch(loadRecordingsIntoTable()); @@ -515,6 +556,11 @@ export const updatePages = () => async (dispatch: AppDispatch, getState: () => R dispatch(loadSeriesIntoTable()); break; } + case "lifeCyclePolicies": { + await dispatch(fetchLifeCyclePolicies()); + dispatch(loadLifeCyclePoliciesIntoTable()); + break; + } case "recordings": { await dispatch(fetchRecordings()); dispatch(loadRecordingsIntoTable()); @@ -627,6 +673,11 @@ export const changeColumnSelection = (updatedColumns: TableConfig["columns"]) => dispatch(loadSeriesIntoTable()); break; } + case "lifeCyclePolicies": { + await dispatch(setLifeCycleColumns(updatedColumns)); + dispatch(loadLifeCyclePoliciesIntoTable()); + break; + } case "recordings": { await dispatch(setRecordingsColumns(updatedColumns)); dispatch(loadRecordingsIntoTable());