From c2c57de5ee2e277bc4531181c23df36845812fd1 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 18 Feb 2026 15:19:33 +0100 Subject: [PATCH 1/7] Add playlists table and API connection This adds the titular table and everything necessary to show all playlists. On its own not of much use, apart from looking at the playlist names. Creating, editing, deleting etc. will be added in a later commit. Needs https://github.com/owi92/opencast/tree/playlist-admin-ui-api to work. --- .gitignore | 3 +- eslint.config.js | 12 +- src/App.tsx | 5 +- src/components/events/Playlists.tsx | 21 ++++ .../events/partials/EventsNavigation.tsx | 31 +++-- .../events/partials/PlaylistCreatorCell.tsx | 18 +++ .../events/partials/PlaylistUpdatedCell.tsx | 18 +++ src/components/shared/EditTableViewModal.tsx | 4 +- .../tableConfigs/playlistsTableConfig.ts | 39 ++++++ src/configs/tableConfigs/playlistsTableMap.ts | 10 ++ .../adminui/languages/lang-en_US.json | 19 +++ src/selectors/playlistSelectors.ts | 9 ++ src/slices/playlistSlice.ts | 114 ++++++++++++++++++ src/slices/tableSlice.ts | 14 ++- src/store.ts | 3 + src/thunks/tableThunks.ts | 54 +++++++++ 16 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 src/components/events/Playlists.tsx create mode 100644 src/components/events/partials/PlaylistCreatorCell.tsx create mode 100644 src/components/events/partials/PlaylistUpdatedCell.tsx create mode 100644 src/configs/tableConfigs/playlistsTableConfig.ts create mode 100644 src/configs/tableConfigs/playlistsTableMap.ts create mode 100644 src/selectors/playlistSelectors.ts create mode 100644 src/slices/playlistSlice.ts diff --git a/.gitignore b/.gitignore index ad3737df3c..d2c9146daa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules /build -.idea/ /target +.idea/ +.editorconfig diff --git a/eslint.config.js b/eslint.config.js index 3df3c47f4d..ce0df9d0fa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,12 +11,12 @@ export default [ { rules: { // TODO: We want to turn these on eventually - "indent": "off", - "max-len": "off", - "no-tabs": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/no-misused-promises": "off", + // "indent": "off", + // "max-len": "off", + // "no-tabs": "off", + // "@typescript-eslint/no-explicit-any": "off", + // "@typescript-eslint/no-floating-promises": "off", + // "@typescript-eslint/no-misused-promises": "off", }, }, ]; diff --git a/src/App.tsx b/src/App.tsx index 3f1a0b7a1c..70bca7e225 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { HashRouter, Navigate, Route, Routes } from "react-router"; +import { Tooltip } from "react-tooltip"; import "./App.scss"; import Events from "./components/events/Events"; import Recordings from "./components/recordings/Recordings"; @@ -13,11 +14,11 @@ import Services from "./components/systems/Services"; import Groups from "./components/users/Groups"; import Acls from "./components/users/Acls"; import About from "./components/About"; +import Playlists from "./components/events/Playlists"; import { useAppDispatch } from "./store"; import { fetchOcVersion, fetchUserInfo } from "./slices/userInfoSlice"; import { subscribeToAuthEvents } from "./utils/broadcastSync"; import { useTableFilterStateValidation } from "./hooks/useTableFilterStateValidation"; -import { Tooltip } from "react-tooltip"; function App() { const dispatch = useAppDispatch(); @@ -48,6 +49,8 @@ function App() { } /> + } /> + } /> } /> diff --git a/src/components/events/Playlists.tsx b/src/components/events/Playlists.tsx new file mode 100644 index 0000000000..1d1602a204 --- /dev/null +++ b/src/components/events/Playlists.tsx @@ -0,0 +1,21 @@ +;import TablePage from "../shared/TablePage"; +import { fetchPlaylists } from "../../slices/playlistSlice"; +import { loadPlaylistsIntoTable } from "../../thunks/tableThunks"; +import { getTotalPlaylists } from "../../selectors/playlistSelectors"; +import { eventsLinks } from "./partials/EventsNavigation"; +import { playlistsTemplateMap } from "../../configs/tableConfigs/playlistsTableMap"; + +/** + * This component renders the table view of playlists + */ +const Playlists = () => ; + +export default Playlists; diff --git a/src/components/events/partials/EventsNavigation.tsx b/src/components/events/partials/EventsNavigation.tsx index 5c21a230ec..53a82c9306 100644 --- a/src/components/events/partials/EventsNavigation.tsx +++ b/src/components/events/partials/EventsNavigation.tsx @@ -4,18 +4,23 @@ import { ParseKeys } from "i18next"; * Utility file for the navigation bar */ export const eventsLinks: { - path: string, - accessRole: string, - text: ParseKeys + path: string, + accessRole: string, + text: ParseKeys }[] = [ - { - path: "/events/events", - accessRole: "ROLE_UI_EVENTS_VIEW", - text: "EVENTS.EVENTS.NAVIGATION.EVENTS", - }, - { - path: "/events/series", - accessRole: "ROLE_UI_SERIES_VIEW", - text: "EVENTS.EVENTS.NAVIGATION.SERIES", - }, + { + path: "/events/events", + accessRole: "ROLE_UI_EVENTS_VIEW", + text: "EVENTS.EVENTS.NAVIGATION.EVENTS", + }, + { + path: "/events/series", + accessRole: "ROLE_UI_SERIES_VIEW", + text: "EVENTS.EVENTS.NAVIGATION.SERIES", + }, + { + path: "/events/playlists", + accessRole: "ROLE_UI_PLAYLISTS_VIEW", + text: "EVENTS.PLAYLISTS.TABLE.CAPTION", + }, ]; diff --git a/src/components/events/partials/PlaylistCreatorCell.tsx b/src/components/events/partials/PlaylistCreatorCell.tsx new file mode 100644 index 0000000000..c3a7de042a --- /dev/null +++ b/src/components/events/partials/PlaylistCreatorCell.tsx @@ -0,0 +1,18 @@ +import { fetchPlaylists, Playlist } from "../../../slices/playlistSlice"; +import { loadPlaylistsIntoTable } from "../../../thunks/tableThunks"; +import MultiValueCell from "../../shared/MultiValueCell"; + +/** + * This component renders the creator cells of playlists in the table view + */ +const PlaylistCreatorCell = ({ row }: { row: Playlist }) => ( + +); + +export default PlaylistCreatorCell; diff --git a/src/components/events/partials/PlaylistUpdatedCell.tsx b/src/components/events/partials/PlaylistUpdatedCell.tsx new file mode 100644 index 0000000000..f6d9818dc9 --- /dev/null +++ b/src/components/events/partials/PlaylistUpdatedCell.tsx @@ -0,0 +1,18 @@ +;import { fetchPlaylists, Playlist } from "../../../slices/playlistSlice"; +import { loadPlaylistsIntoTable } from "../../../thunks/tableThunks"; +import DateTimeCell from "../../shared/DateTimeCell"; + +/** + * This component renders the updated date cells of playlists in the table view + */ +const PlaylistUpdatedCell = ({ row }: { row: Playlist }) => row.updated !== undefined ? ( + +) : <>; + +export default PlaylistUpdatedCell; diff --git a/src/components/shared/EditTableViewModal.tsx b/src/components/shared/EditTableViewModal.tsx index 70d396614c..65afe7a532 100644 --- a/src/components/shared/EditTableViewModal.tsx +++ b/src/components/shared/EditTableViewModal.tsx @@ -13,6 +13,7 @@ import ButtonLikeAnchor from "./ButtonLikeAnchor"; import { aclsTableConfig, TableColumn } from "../../configs/tableConfigs/aclsTableConfig"; import { eventsTableConfig } from "../../configs/tableConfigs/eventsTableConfig"; import { seriesTableConfig } from "../../configs/tableConfigs/seriesTableConfig"; +import { playlistsTableConfig } from "../../configs/tableConfigs/playlistsTableConfig"; import { recordingsTableConfig } from "../../configs/tableConfigs/recordingsTableConfig"; import { jobsTableConfig } from "../../configs/tableConfigs/jobsTableConfig"; import { serversTableConfig } from "../../configs/tableConfigs/serversTableConfig"; @@ -126,6 +127,7 @@ const EditTableViewModalContent = ({ switch (resource) { case "events": return eventsTableConfig; case "series": return seriesTableConfig; + case "playlists": return playlistsTableConfig; case "recordings": return recordingsTableConfig; case "jobs": return jobsTableConfig; case "servers": return serversTableConfig; @@ -150,7 +152,7 @@ const EditTableViewModalContent = ({ const getTranslationForSubheading = (resource: Resource): ParseKeys | undefined => { const resourceUC: Uppercase = resource.toUpperCase() as Uppercase; - if (resourceUC === "EVENTS" || resourceUC === "SERIES") { + if (resourceUC === "EVENTS" || resourceUC === "SERIES" || resourceUC === "PLAYLISTS") { return `EVENTS.${resourceUC}.TABLE.CAPTION`; } if (resourceUC === "RECORDINGS") { diff --git a/src/configs/tableConfigs/playlistsTableConfig.ts b/src/configs/tableConfigs/playlistsTableConfig.ts new file mode 100644 index 0000000000..339cbcb564 --- /dev/null +++ b/src/configs/tableConfigs/playlistsTableConfig.ts @@ -0,0 +1,39 @@ +import { TableConfig } from "./aclsTableConfig"; + +/** + * Config that contains the columns and further information regarding playlists. + * Information configured in this file: + * - columns: names, labels, sortable, (template) + * - caption for showing in table view + * - resource type (here: playlists) + * - category type (here: events) + */ +export const playlistsTableConfig: TableConfig = { + columns: [ + { + name: "title", + label: "EVENTS.PLAYLISTS.TABLE.TITLE", + sortable: true, + }, + { + name: "description", + label: "EVENTS.PLAYLISTS.TABLE.DESCRIPTION", + }, + { + template: "PlaylistCreatorCell", + name: "creator", + label: "EVENTS.PLAYLISTS.TABLE.CREATOR", + sortable: true, + }, + { + template: "PlaylistUpdatedCell", + name: "updated", + label: "EVENTS.PLAYLISTS.TABLE.UPDATED", + sortable: true, + }, + ], + caption: "EVENTS.PLAYLISTS.TABLE.CAPTION", + resource: "playlists", + category: "events", + multiSelect: false, +}; diff --git a/src/configs/tableConfigs/playlistsTableMap.ts b/src/configs/tableConfigs/playlistsTableMap.ts new file mode 100644 index 0000000000..ea958ebfc0 --- /dev/null +++ b/src/configs/tableConfigs/playlistsTableMap.ts @@ -0,0 +1,10 @@ +import PlaylistCreatorCell from "../../components/events/partials/PlaylistCreatorCell"; +import PlaylistUpdatedCell from "../../components/events/partials/PlaylistUpdatedCell"; + +/** + * This map contains the mapping between the template strings and the corresponding react component. + */ +export const playlistsTemplateMap = { + PlaylistCreatorCell: PlaylistCreatorCell, + PlaylistUpdatedCell: PlaylistUpdatedCell, +}; 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 27ad431218..253f642b4d 100644 --- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json +++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json @@ -627,6 +627,7 @@ "NAVIGATION": { "EVENTS": "Events", "SERIES": "Series", + "PLAYLISTS": "Playlists", "OVERVIEW": "Overview", "LABEL": "Switch between events and series." }, @@ -1289,6 +1290,16 @@ "LINK": "Link" } } + }, + "PLAYLISTS": { + "TABLE": { + "CAPTION": "Playlists", + "TITLE": "Title", + "DESCRIPTION": "Description", + "CREATOR": "Creator", + "UPDATED": "Updated", + "ACTION": "Action" + } } }, "RECORDINGS": { @@ -1998,6 +2009,14 @@ "LABEL": "Created" } }, + "PLAYLISTS": { + "CREATOR": { + "LABEL": "Creator" + }, + "UPDATED": { + "LABEL": "Updated" + } + }, "USERS": { "PROVIDER": { "LABEL": "Provider" diff --git a/src/selectors/playlistSelectors.ts b/src/selectors/playlistSelectors.ts new file mode 100644 index 0000000000..447f54d993 --- /dev/null +++ b/src/selectors/playlistSelectors.ts @@ -0,0 +1,9 @@ +import { RootState } from "../store"; + +/** + * This file contains selectors regarding playlists + */ +export const getPlaylists = (state: RootState) => state.playlists.results; +export const getVisibilityPlaylistColumns = (state: RootState) => state.playlists.columns; +export const isLoading = (state: RootState) => state.playlists.status === "loading"; +export const getTotalPlaylists = (state: RootState) => state.playlists.total; diff --git a/src/slices/playlistSlice.ts b/src/slices/playlistSlice.ts new file mode 100644 index 0000000000..473af4cc7b --- /dev/null +++ b/src/slices/playlistSlice.ts @@ -0,0 +1,114 @@ +import { createSlice, PayloadAction, SerializedError } from "@reduxjs/toolkit"; +import axios from "axios"; + +import { TableConfig } from "../configs/tableConfigs/aclsTableConfig"; +import { createAppAsyncThunk } from "../createAsyncThunkWithTypes"; +import { getURLParams } from "../utils/resourceUtils"; +import { playlistsTableConfig } from "../configs/tableConfigs/playlistsTableConfig"; + + +export type Playlist = { + id: string; + organization?: string; + entries: { + id: number; + contentId: string; + type: string; + }[]; + title: string; + description: string; + creator: string; + updated: string; + accessControlEntries: { + id?: number; + allow: boolean; + role: string; + action: string; + }[]; +}; + + +type PlaylistState = { + status: "uninitialized" | "loading" | "succeeded" | "failed", + error: SerializedError | null, + results: Playlist[], + columns: TableConfig["columns"], + total: number, + count: number, + offset: number, + limit: number, +} + + +const initialColumns = playlistsTableConfig.columns.map(column => ({ + ...column, + deactivated: false, +})); + + +const initialState: PlaylistState = { + status: "uninitialized", + error: null, + results: [], + columns: initialColumns, + total: 0, + count: 0, + offset: 0, + limit: 0, +}; + + +type FetchPlaylists = { + total: number, + count: number, + offset: number, + limit: number, + results: Playlist[], +}; + + +export const fetchPlaylists = createAppAsyncThunk("playlists/fetchPlaylists", async (_, { getState }) => { + const state = getState(); + const params = getURLParams(state, "playlists"); + + const res = await axios.get("/admin-ng/playlists", { params: params }); + + return res.data; +}); + +const playlistSlice = createSlice({ + name: "playlist", + initialState, + reducers: { + setPlaylistColumns(state, action: PayloadAction) { + state.columns = action.payload; + }, + }, + + extraReducers: builder => { + builder + .addCase(fetchPlaylists.pending, state => { + state.status = "loading"; + }) + .addCase(fetchPlaylists.fulfilled, (state, action: PayloadAction) => { + state.status = "succeeded"; + const playlist = action.payload; + state.limit = playlist.limit; + state.offset = playlist.offset; + state.results = playlist.results; + state.total = playlist.total; + state.count = playlist.count; + }) + .addCase(fetchPlaylists.rejected, (state, action) => { + state.status = "failed"; + state.error = action.error; + }); + }, +}); + +export const { + setPlaylistColumns, +} = playlistSlice.actions; + +export default playlistSlice.reducer; + diff --git a/src/slices/tableSlice.ts b/src/slices/tableSlice.ts index 997aafe23a..eef6905192 100644 --- a/src/slices/tableSlice.ts +++ b/src/slices/tableSlice.ts @@ -9,9 +9,11 @@ import { Group } from "./groupSlice"; import { AclResult } from "./aclSlice"; import { ThemeDetailsType } from "./themeSlice"; import { Series } from "./seriesSlice"; +import { Playlist } from "./playlistSlice"; import { Event } from "./eventSlice"; import { eventsTableConfig } from "../configs/tableConfigs/eventsTableConfig"; import { seriesTableConfig } from "../configs/tableConfigs/seriesTableConfig"; +import { playlistsTableConfig } from "../configs/tableConfigs/playlistsTableConfig"; import { recordingsTableConfig } from "../configs/tableConfigs/recordingsTableConfig"; import { jobsTableConfig } from "../configs/tableConfigs/jobsTableConfig"; import { serversTableConfig } from "../configs/tableConfigs/serversTableConfig"; @@ -69,7 +71,7 @@ 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: Row | Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType): row is Event { return (row as Event).event_status !== undefined; } @@ -81,13 +83,14 @@ export function isSeries(row: Row | Event | Series | Recording | Server | Job | export type Row = { id: string, // For use with entityAdapter. Directly taken from event/series etc. if available selected: boolean // If the row was marked in the ui by the user -} & (Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType) +} & (Event | Series | Playlist | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType) export type SubmitRow = { selected: boolean -} & (Event | Series | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType) +} & (Event | Series | Playlist | Recording | Server | Job | Service | User | Group | AclResult | ThemeDetailsType) -export type Resource = "events" | "series" | "recordings" | "jobs" | "servers" | "services" | "users" | "groups" | "acls" | "themes" +export type Resource = "events" | "series" | "playlists" | "recordings" + | "jobs" | "servers" | "services" | "users" | "groups" | "acls" | "themes"; export type ReverseOptions = "ASC" | "DESC" | "NONE" @@ -135,6 +138,7 @@ const initialState: TableState = { multiSelect: { events: eventsTableConfig.multiSelect, series: seriesTableConfig.multiSelect, + playlists: playlistsTableConfig.multiSelect, recordings: recordingsTableConfig.multiSelect, jobs: jobsTableConfig.multiSelect, servers: serversTableConfig.multiSelect, @@ -150,6 +154,7 @@ const initialState: TableState = { sortBy: { events: "date", series: "createdDateTime", + playlists: "updated", recordings: "status", jobs: "id", servers: "online", @@ -163,6 +168,7 @@ const initialState: TableState = { reverse: { events: "DESC", series: "DESC", + playlists: "DESC", recordings: "ASC", jobs: "ASC", servers: "ASC", diff --git a/src/store.ts b/src/store.ts index 8983774bc4..8fea04c4ca 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 playlists from "./slices/playlistSlice"; import recordings from "./slices/recordingSlice"; import jobs from "./slices/jobSlice"; import servers from "./slices/serverSlice"; @@ -38,6 +39,7 @@ import autoMergeLevel2 from "redux-persist/es/stateReconciler/autoMergeLevel2"; const tableFilterProfilesPersistConfig = { key: "tableFilterProfiles", storage, whitelist: ["profiles"] }; const eventsPersistConfig = { key: "events", storage, whitelist: ["columns"] }; const seriesPersistConfig = { key: "series", storage, whitelist: ["columns"] }; +const playlistPersistConfig = { key: "playlists", storage, whitelist: ["columns"] }; const tablePersistConfig = { key: "table", storage, whitelist: ["pagination", "sortBy", "reverse"] }; const recordingsPersistConfig = { key: "recordings", storage, whitelist: ["columns"] }; const jobsPersistConfig = { key: "jobs", storage, whitelist: ["columns"] }; @@ -54,6 +56,7 @@ const reducers = combineReducers({ tableFilterProfiles: persistReducer(tableFilterProfilesPersistConfig, tableFilterProfiles), events: persistReducer(eventsPersistConfig, events), series: persistReducer(seriesPersistConfig, series), + playlists: persistReducer(playlistPersistConfig, playlists), table: persistReducer(tablePersistConfig, table), recordings: persistReducer(recordingsPersistConfig, recordings), jobs: persistReducer(jobsPersistConfig, jobs), diff --git a/src/thunks/tableThunks.ts b/src/thunks/tableThunks.ts index f3d78dfba5..266aac43b3 100644 --- a/src/thunks/tableThunks.ts +++ b/src/thunks/tableThunks.ts @@ -35,6 +35,7 @@ import { fetchRecordings, setRecordingsColumns } from "../slices/recordingSlice" import { setGroupColumns } from "../slices/groupSlice"; import { fetchAcls, setAclColumns } from "../slices/aclSlice"; import { AppDispatch, AppThunk, RootState } from "../store"; +import { fetchPlaylists, setPlaylistColumns } from "../slices/playlistSlice"; /** * This file contains methods/thunks used to manage the table in the main view and its state changes @@ -121,6 +122,44 @@ export const loadSeriesIntoTable = (): AppThunk => (dispatch, getState) => { dispatch(loadResourceIntoTable(tableData)); }; + +export const loadPlaylistsIntoTable = (): AppThunk => (dispatch, getState) => { + const { playlists, table } = getState(); + const total = playlists.total; + const pagination = table.pagination; + + const resource = playlists.results.map(result => { + const current = table.rows.entities[result.id]; + + if (!!current && table.resource === "playlists") { + return { + ...result, + selected: current.selected, + }; + } else { + return { + ...result, + selected: false, + }; + } + }); + + const pages = calculatePages(total / pagination.limit, pagination.offset); + + const tableData = { + resource: "playlists" as const, + rows: resource, + columns: playlists.columns, + multiSelect: table.multiSelect["playlists"], + pages: pages, + sortBy: table.sortBy["playlists"], + reverse: table.reverse["playlists"], + totalItems: total, + }; + + dispatch(loadResourceIntoTable(tableData)); +}; + export const loadRecordingsIntoTable = (): AppThunk => (dispatch, getState) => { const { recordings, table } = getState(); const pagination = table.pagination; @@ -338,6 +377,11 @@ export const goToPage = (pageNumber: number) => async (dispatch: AppDispatch, ge dispatch(loadSeriesIntoTable()); break; } + case "playlists": { + await dispatch(fetchPlaylists()); + dispatch(loadPlaylistsIntoTable()); + break; + } case "recordings": { await dispatch(fetchRecordings()); dispatch(loadRecordingsIntoTable()); @@ -411,6 +455,11 @@ export const updatePages = () => async (dispatch: AppDispatch, getState: () => R dispatch(loadRecordingsIntoTable()); break; } + case "playlists": { + await dispatch(fetchPlaylists()); + dispatch(loadPlaylistsIntoTable()); + break; + } case "jobs": { await dispatch(fetchJobs()); dispatch(loadJobsIntoTable()); @@ -515,6 +564,11 @@ export const changeColumnSelection = (updatedColumns: TableConfig["columns"]) => dispatch(loadSeriesIntoTable()); break; } + case "playlists": { + dispatch(setPlaylistColumns(updatedColumns)); + dispatch(loadPlaylistsIntoTable()); + break; + } case "recordings": { dispatch(setRecordingsColumns(updatedColumns)); dispatch(loadRecordingsIntoTable()); From fefa6be44c754e11e11044c6c7b0cca6c1be0d34 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 25 Feb 2026 17:28:24 +0100 Subject: [PATCH 2/7] Add basic playlist details modal Features metadata and ACL display and editing. Todo: Figure out UI/UX for adding and removing entries. --- src/components/events/Playlists.tsx | 23 +- .../ModalTabsAndPages/DetailsMetadataTab.tsx | 9 +- .../PlaylistDetailsAccessTab.tsx | 60 +++++ .../events/partials/PlaylistActionsCell.tsx | 38 +++ .../partials/modals/PlaylistDetails.tsx | 107 ++++++++ .../partials/modals/PlaylistDetailsModal.tsx | 67 +++++ .../tableConfigs/playlistsTableConfig.ts | 5 + src/configs/tableConfigs/playlistsTableMap.ts | 2 + .../adminui/languages/lang-en_US.json | 35 ++- src/selectors/playlistDetailsSelectors.ts | 13 + src/slices/playlistDetailsSlice.ts | 255 ++++++++++++++++++ src/store.ts | 62 ++--- 12 files changed, 632 insertions(+), 44 deletions(-) create mode 100644 src/components/events/partials/ModalTabsAndPages/PlaylistDetailsAccessTab.tsx create mode 100644 src/components/events/partials/PlaylistActionsCell.tsx create mode 100644 src/components/events/partials/modals/PlaylistDetails.tsx create mode 100644 src/components/events/partials/modals/PlaylistDetailsModal.tsx create mode 100644 src/selectors/playlistDetailsSelectors.ts create mode 100644 src/slices/playlistDetailsSlice.ts diff --git a/src/components/events/Playlists.tsx b/src/components/events/Playlists.tsx index 1d1602a204..dd36a4ec38 100644 --- a/src/components/events/Playlists.tsx +++ b/src/components/events/Playlists.tsx @@ -4,18 +4,23 @@ import { loadPlaylistsIntoTable } from "../../thunks/tableThunks"; import { getTotalPlaylists } from "../../selectors/playlistSelectors"; import { eventsLinks } from "./partials/EventsNavigation"; import { playlistsTemplateMap } from "../../configs/tableConfigs/playlistsTableMap"; +import PlaylistDetailsModal from "./partials/modals/PlaylistDetailsModal"; + /** * This component renders the table view of playlists */ -const Playlists = () => ; +const Playlists = () => <> + + +; export default Playlists; diff --git a/src/components/events/partials/ModalTabsAndPages/DetailsMetadataTab.tsx b/src/components/events/partials/ModalTabsAndPages/DetailsMetadataTab.tsx index 58f1eba094..53026dd08c 100644 --- a/src/components/events/partials/ModalTabsAndPages/DetailsMetadataTab.tsx +++ b/src/components/events/partials/ModalTabsAndPages/DetailsMetadataTab.tsx @@ -21,8 +21,9 @@ import ModalContentTable from "../../../shared/modals/ModalContentTable"; import { addNotification } from "../../../../slices/notificationSlice"; import { NOTIFICATION_CONTEXT } from "../../../../configs/modalConfig"; -type InitialValues = { - [key: string]: string | string[]; + +export type MetadataValues = { + [key: string]: string | string[]; } /** @@ -44,7 +45,7 @@ const DetailsMetadataTab = ({ catalog: MetadataCatalog; }, any> // (id: string, values: { [key: string]: any }, catalog: MetadataCatalog) => void, editAccessRole: string, - formikRef?: React.RefObject | null> + formikRef?: React.RefObject | null> header?: ParseKeys }) => { const { t } = useTranslation(); @@ -99,7 +100,7 @@ const DetailsMetadataTab = ({ > {metadata.map(catalog => ( // initialize form - + key={catalog.flavor} enableReinitialize initialValues={getInitialValues(catalog)} diff --git a/src/components/events/partials/ModalTabsAndPages/PlaylistDetailsAccessTab.tsx b/src/components/events/partials/ModalTabsAndPages/PlaylistDetailsAccessTab.tsx new file mode 100644 index 0000000000..12a0e09e9f --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/PlaylistDetailsAccessTab.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { ParseKeys } from "i18next"; + +import ResourceDetailsAccessPolicyTab from "../../../shared/modals/ResourceDetailsAccessPolicyTab"; +import { + getPlaylistDetailsAcl, + getPlaylistDetailsPolicyTemplateId, +} from "../../../../selectors/playlistDetailsSelectors"; +import { fetchPlaylistDetails, updatePlaylistAccess } from "../../../../slices/playlistDetailsSlice"; +import { removeNotificationWizardForm } from "../../../../slices/notificationSlice"; +import { useAppDispatch, useAppSelector } from "../../../../store"; + + +/** + * This component manages the access policy tab of the playlist details modal + */ +const PlaylistDetailsAccessTab = ({ + playlistId, + header, + policyChanged, + setPolicyChanged, +}: { + playlistId: string, + header: ParseKeys, + policyChanged: boolean, + setPolicyChanged: (value: boolean) => void, +}) => { + const dispatch = useAppDispatch(); + + const policies = useAppSelector(state => getPlaylistDetailsAcl(state)); + const policyTemplateId = useAppSelector(state => getPlaylistDetailsPolicyTemplateId(state)); + + useEffect(() => { + dispatch(removeNotificationWizardForm()); + }, [dispatch]); + + return ; +}; + +export default PlaylistDetailsAccessTab; diff --git a/src/components/events/partials/PlaylistActionsCell.tsx b/src/components/events/partials/PlaylistActionsCell.tsx new file mode 100644 index 0000000000..a285a03601 --- /dev/null +++ b/src/components/events/partials/PlaylistActionsCell.tsx @@ -0,0 +1,38 @@ +import { LuFileText } from "react-icons/lu"; + +import { fetchPlaylistDetails, openModal } from "../../../slices/playlistDetailsSlice"; +import { useAppDispatch } from "../../../store"; +import { Playlist } from "../../../slices/playlistSlice"; +import ButtonLikeAnchor from "../../shared/ButtonLikeAnchor"; +import { PlaylistDetailsPage } from "./modals/PlaylistDetails"; + + +/** + * This component renders the action cells of playlists in the table view + */ +const PlaylistActionsCell = ({ + row, +}: { + row: Playlist +}) => { + const dispatch = useAppDispatch(); + + const showPlaylistDetailsModal = async () => { + await dispatch(fetchPlaylistDetails(row.id)); + + dispatch(openModal(PlaylistDetailsPage.Metadata, { id: row.id, title: row.title })); + }; + + return <> + {/* playlist details */} + showPlaylistDetailsModal()} + className={"action-cell-button"} + editAccessRole={"ROLE_UI_PLAYLISTS_DETAILS_VIEW"} + > + + + ; +}; + +export default PlaylistActionsCell; diff --git a/src/components/events/partials/modals/PlaylistDetails.tsx b/src/components/events/partials/modals/PlaylistDetails.tsx new file mode 100644 index 0000000000..4f12d0761b --- /dev/null +++ b/src/components/events/partials/modals/PlaylistDetails.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import cn from "classnames"; +import { FormikProps } from "formik"; +import { ParseKeys } from "i18next"; + +import { getUserInformation } from "../../../../selectors/userInfoSelectors"; +import { confirmUnsaved, hasAccess } from "../../../../utils/utils"; +import { useAppSelector } from "../../../../store"; +import { getPlaylistDetailsMetadata } from "../../../../selectors/playlistDetailsSelectors"; +import { updatePlaylistMetadata } from "../../../../slices/playlistDetailsSlice"; +import ButtonLikeAnchor from "../../../shared/ButtonLikeAnchor"; +import DetailsMetadataTab, { MetadataValues } from "../ModalTabsAndPages/DetailsMetadataTab"; +import PlaylistDetailsAccessTab from "../ModalTabsAndPages/PlaylistDetailsAccessTab"; + + +export enum PlaylistDetailsPage { + Metadata, + AccessPolicy, +} + +/** + * This component manages the tabs of the playlist details modal + */ +const PlaylistDetails = ({ + playlistId, + policyChanged, + setPolicyChanged, + formikRef, +}: { + playlistId: string, + policyChanged: boolean, + setPolicyChanged: (value: boolean) => void, + formikRef: React.RefObject | null>, +}) => { + const { t } = useTranslation(); + + const metadata = useAppSelector(state => getPlaylistDetailsMetadata(state)); + const user = useAppSelector(state => getUserInformation(state)); + + const [page, setPage] = useState(0); + + const tabs: { + tabNameTranslation: ParseKeys, + accessRole: string, + name: string, + }[] = [ + { + tabNameTranslation: "EVENTS.PLAYLISTS.DETAILS.TABS.METADATA", + accessRole: "ROLE_UI_PLAYLISTS_DETAILS_METADATA_VIEW", + name: "metadata", + }, + { + tabNameTranslation: "EVENTS.PLAYLISTS.DETAILS.TABS.PERMISSIONS", + accessRole: "ROLE_UI_PLAYLISTS_DETAILS_ACL_VIEW", + name: "permissions", + }, + ]; + + const openTab = (tabNr: number) => { + let isUnsavedChanges = policyChanged; + if (formikRef.current?.dirty) { + isUnsavedChanges = true; + } + + if (!isUnsavedChanges || confirmUnsaved(t)) { + setPage(tabNr); + } + }; + + return <> + {/* Tab navigation */} + + + {/* Tab content */} +
+ {page === 0 && ( + + )} + {page === 1 && } +
+ ; +}; + +export default PlaylistDetails; diff --git a/src/components/events/partials/modals/PlaylistDetailsModal.tsx b/src/components/events/partials/modals/PlaylistDetailsModal.tsx new file mode 100644 index 0000000000..045a7a9bf1 --- /dev/null +++ b/src/components/events/partials/modals/PlaylistDetailsModal.tsx @@ -0,0 +1,67 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FormikProps } from "formik"; + +import PlaylistDetails from "./PlaylistDetails"; +import { removeNotificationWizardForm } from "../../../../slices/notificationSlice"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { Modal } from "../../../shared/modals/Modal"; +import { confirmUnsaved } from "../../../../utils/utils"; +import { MetadataValues } from "../ModalTabsAndPages/DetailsMetadataTab"; +import { setModalPlaylist, setShowModal } from "../../../../slices/playlistDetailsSlice"; +import { getModalPlaylist, showModal } from "../../../../selectors/playlistDetailsSelectors"; + + +/** + * This component renders the modal for displaying playlist details + */ +const PlaylistDetailsModal = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const [policyChanged, setPolicyChanged] = useState(false); + const formikRef = useRef>(null); + + const displayPlaylistDetailsModal = useAppSelector(state => showModal(state)); + const playlist = useAppSelector(state => getModalPlaylist(state))!; + + const hideModal = () => { + dispatch(setModalPlaylist(null)); + dispatch(setShowModal(false)); + }; + + const close = () => { + let isUnsavedChanges = policyChanged; + if (formikRef.current?.dirty) { + isUnsavedChanges = true; + } + + if (!isUnsavedChanges || confirmUnsaved(t)) { + setPolicyChanged(false); + dispatch(removeNotificationWizardForm()); + hideModal(); + return true; + } + return false; + }; + + return <> + {displayPlaylistDetailsModal && + + setPolicyChanged(value)} + formikRef={formikRef} + /> + + } + ; +}; + +export default PlaylistDetailsModal; diff --git a/src/configs/tableConfigs/playlistsTableConfig.ts b/src/configs/tableConfigs/playlistsTableConfig.ts index 339cbcb564..5520a3fcd4 100644 --- a/src/configs/tableConfigs/playlistsTableConfig.ts +++ b/src/configs/tableConfigs/playlistsTableConfig.ts @@ -31,6 +31,11 @@ export const playlistsTableConfig: TableConfig = { label: "EVENTS.PLAYLISTS.TABLE.UPDATED", sortable: true, }, + { + template: "PlaylistActionsCell", + name: "actions", + label: "EVENTS.PLAYLISTS.TABLE.ACTION", + }, ], caption: "EVENTS.PLAYLISTS.TABLE.CAPTION", resource: "playlists", diff --git a/src/configs/tableConfigs/playlistsTableMap.ts b/src/configs/tableConfigs/playlistsTableMap.ts index ea958ebfc0..fee3d69f9c 100644 --- a/src/configs/tableConfigs/playlistsTableMap.ts +++ b/src/configs/tableConfigs/playlistsTableMap.ts @@ -1,5 +1,6 @@ import PlaylistCreatorCell from "../../components/events/partials/PlaylistCreatorCell"; import PlaylistUpdatedCell from "../../components/events/partials/PlaylistUpdatedCell"; +import PlaylistActionsCell from "../../components/events/partials/PlaylistActionsCell"; /** * This map contains the mapping between the template strings and the corresponding react component. @@ -7,4 +8,5 @@ import PlaylistUpdatedCell from "../../components/events/partials/PlaylistUpdate export const playlistsTemplateMap = { PlaylistCreatorCell: PlaylistCreatorCell, PlaylistUpdatedCell: PlaylistUpdatedCell, + PlaylistActionsCell: PlaylistActionsCell, }; 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 253f642b4d..608d53c19f 100644 --- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json +++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json @@ -1298,7 +1298,40 @@ "DESCRIPTION": "Description", "CREATOR": "Creator", "UPDATED": "Updated", - "ACTION": "Action" + "ACTION": "Action", + "TOOLTIP": { + "DETAILS": "Open playlist details" + } + }, + "DETAILS": { + "HEADER": "Playlist details - {{name}}", + "TABS": { + "METADATA": "Metadata", + "PERMISSIONS": "Access policy" + }, + "METADATA": { + "TITLE": "Title", + "DESCRIPTION": "Description", + "CREATOR": "Creator", + "UPDATED": "Updated", + "ENTRIES": "Entries" + }, + "ACCESS": { + "ACCESS_POLICY": { + "LABEL": "Select a template", + "DESCRIPTION": "" + }, + "NON_USER_ROLES": "Roles and Groups authorized for the playlist", + "ROLE": "Role", + "READ": "Read", + "WRITE": "Write", + "ADDITIONAL_ACTIONS": "Additional Actions", + "NEW": "New policy", + "USERS": "Users who are authorized for the playlist", + "USER": "User", + "NEW_USER": "New user", + "EMPTY": "No access policies defined" + } } } }, diff --git a/src/selectors/playlistDetailsSelectors.ts b/src/selectors/playlistDetailsSelectors.ts new file mode 100644 index 0000000000..e0082c62cc --- /dev/null +++ b/src/selectors/playlistDetailsSelectors.ts @@ -0,0 +1,13 @@ +import { RootState } from "../store"; + +/** + * This file contains selectors regarding details of a playlist + */ + +export const showModal = (state: RootState) => state.playlistDetails.modal.show; +export const getModalPage = (state: RootState) => state.playlistDetails.modal.page; +export const getModalPlaylist = (state: RootState) => state.playlistDetails.modal.playlist; + +export const getPlaylistDetailsMetadata = (state: RootState) => state.playlistDetails.metadata; +export const getPlaylistDetailsAcl = (state: RootState) => state.playlistDetails.acl; +export const getPlaylistDetailsPolicyTemplateId = (state: RootState) => state.playlistDetails.policyTemplateId; diff --git a/src/slices/playlistDetailsSlice.ts b/src/slices/playlistDetailsSlice.ts new file mode 100644 index 0000000000..8e43abc400 --- /dev/null +++ b/src/slices/playlistDetailsSlice.ts @@ -0,0 +1,255 @@ +import { PayloadAction, SerializedError, createSlice } from "@reduxjs/toolkit"; +import axios from "axios"; + +import { createAppAsyncThunk } from "../createAsyncThunkWithTypes"; +import { Playlist } from "./playlistSlice"; +import { TransformedAcl } from "./aclDetailsSlice"; +import { Acl } from "./aclSlice"; +import { MetadataCatalog } from "./eventSlice"; +import { addNotification } from "./notificationSlice"; +import { NOTIFICATION_CONTEXT } from "../configs/modalConfig"; +import { AppDispatch } from "../store"; +import { PlaylistDetailsPage } from "../components/events/partials/modals/PlaylistDetails"; + + +/** + * This file contains redux reducer for actions affecting the state of playlist details + */ +type PlaylistDetailsModal = { + show: boolean, + page: PlaylistDetailsPage, + playlist: { id: string, title: string } | null, +} + +type PlaylistDetailsState = { + statusMetadata: "uninitialized" | "loading" | "succeeded" | "failed", + errorMetadata: SerializedError | null, + modal: PlaylistDetailsModal, + metadata: MetadataCatalog, + acl: TransformedAcl[], + policyTemplateId: number, +} + +/** Converts raw playlist response into `MetadataCatalog` format */ +const playlistToMetadataCatalog = (playlist: Playlist): MetadataCatalog => ({ + title: "EVENTS.PLAYLISTS.DETAILS.TABS.METADATA", + flavor: "playlist/details", + fields: [ + { + id: "title", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.TITLE", + readOnly: false, + required: true, + type: "text", + value: playlist.title, + }, + { + id: "description", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.DESCRIPTION", + readOnly: false, + required: false, + type: "text_long", + value: playlist.description, + }, + { + id: "creator", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.CREATOR", + readOnly: false, + required: false, + type: "text", + value: playlist.creator, + }, + { + id: "updated", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.UPDATED", + readOnly: true, + required: false, + type: "date", + value: playlist.updated, + }, + { + id: "entries", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.ENTRIES", + readOnly: true, + required: false, + type: "text", + value: String(playlist.entries.length), + }, + ], +}); + +const initialState: PlaylistDetailsState = { + statusMetadata: "uninitialized", + errorMetadata: null, + modal: { + show: false, + page: PlaylistDetailsPage.Metadata, + playlist: null, + }, + metadata: { + title: "", + flavor: "", + fields: [], + }, + acl: [], + policyTemplateId: 0, +}; + +/** Transforms raw ACL into the `TransformedAcl` format required by the UI */ +const transformPlaylistAcl = (entries: Playlist["accessControlEntries"]): TransformedAcl[] => { + const acl: TransformedAcl[] = []; + + for (const entry of entries || []) { + const existing = acl.find(a => a.role === entry.role); + + if (existing) { + existing.read = existing.read || (entry.action === "read" && entry.allow); + existing.write = existing.write || (entry.action === "write" && entry.allow); + if (entry.action !== "read" && entry.action !== "write") { + existing.actions = [...existing.actions, entry.action]; + } + } else { + acl.push({ + role: entry.role, + read: entry.action === "read" && entry.allow, + write: entry.action === "write" && entry.allow, + actions: (entry.action !== "read" && entry.action !== "write") ? [entry.action] : [], + }); + } + } + + return acl; +}; + + +export const fetchPlaylistDetails = createAppAsyncThunk("playlistDetails/fetchPlaylistDetails", async (id: string) => { + const res = await axios.get(`/admin-ng/playlists/${id}`); + const playlist = res.data; + + return { + metadata: playlistToMetadataCatalog(playlist), + acl: transformPlaylistAcl(playlist.accessControlEntries), + }; +}); + + +export const updatePlaylistMetadata = createAppAsyncThunk("playlistDetails/updatePlaylistMetadata", async (params: { + id: string, + values: { [key: string]: MetadataCatalog["fields"][0]["value"] }, + catalog: MetadataCatalog, +}, { dispatch }) => { + const { id, values, catalog } = params; + + const updatePayload: Record = {}; + for (const field of catalog.fields) { + if (!field.readOnly && field.id in values) { + updatePayload[field.id] = values[field.id]; + } + } + + const data = new URLSearchParams(); + data.append("playlist", JSON.stringify(updatePayload)); + + const res = await axios.put(`/admin-ng/playlists/${id}`, data, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + + const updatedCatalog = playlistToMetadataCatalog(res.data); + dispatch(setPlaylistDetailsMetadata(updatedCatalog)); +}); + + +export const updatePlaylistAccess = createAppAsyncThunk("playlistDetails/updatePlaylistAccess", async (params: { + id: string, + policies: { acl: Acl }, + override?: boolean, +}, { dispatch }) => { + const { id, policies } = params; + + // Convert ACL back to the format expected by the API. + const accessControlEntries = policies.acl.ace.map(ace => ({ + allow: ace.allow, + role: ace.role, + action: ace.action, + })); + + const data = new URLSearchParams(); + data.append("playlist", JSON.stringify({ accessControlEntries })); + + await axios.put(`/admin-ng/playlists/${id}`, data, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + + dispatch(addNotification({ + type: "info", + key: "SAVED_ACL_RULES", + duration: -1, + context: NOTIFICATION_CONTEXT, + })); + + // Refetch to get updated ACL + const res = await axios.get(`/admin-ng/playlists/${id}`); + dispatch(setPlaylistDetailsAcl(transformPlaylistAcl(res.data.accessControlEntries))); + + return true; +}); + + +export const openModal = ( + page: PlaylistDetailsPage, + playlist: PlaylistDetailsModal["playlist"], +) => (dispatch: AppDispatch) => { + dispatch(setModalPlaylist(playlist)); + dispatch(setModalPage(page)); + dispatch(setShowModal(true)); +}; + +const playlistDetailsSlice = createSlice({ + name: "playlistDetails", + initialState, + reducers: { + setShowModal(state, action: PayloadAction) { + state.modal.show = action.payload; + }, + setModalPage(state, action: PayloadAction) { + state.modal.page = action.payload; + }, + setModalPlaylist(state, action: PayloadAction) { + state.modal.playlist = action.payload; + }, + setPlaylistDetailsMetadata(state, action: PayloadAction) { + state.metadata = action.payload; + }, + setPlaylistDetailsAcl(state, action: PayloadAction) { + state.acl = action.payload; + }, + }, + extraReducers: builder => { + builder + .addCase(fetchPlaylistDetails.pending, state => { + state.statusMetadata = "loading"; + }) + .addCase(fetchPlaylistDetails.fulfilled, (state, action: PayloadAction<{ + metadata: MetadataCatalog, + acl: TransformedAcl[], + }>) => { + state.statusMetadata = "succeeded"; + state.metadata = action.payload.metadata; + state.acl = action.payload.acl; + }) + .addCase(fetchPlaylistDetails.rejected, (state, action) => { + state.statusMetadata = "failed"; + state.errorMetadata = action.error; + }); + }, +}); + +export const { + setShowModal, + setModalPage, + setModalPlaylist, + setPlaylistDetailsMetadata, + setPlaylistDetailsAcl, +} = playlistDetailsSlice.actions; + +export default playlistDetailsSlice.reducer; diff --git a/src/store.ts b/src/store.ts index 8fea04c4ca..40fa1004c7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -24,6 +24,7 @@ import userDetails from "./slices/userDetailsSlice"; import recordingDetails from "./slices/recordingDetailsSlice"; import groupDetails from "./slices/groupDetailsSlice"; import aclDetails from "./slices/aclDetailsSlice"; +import playlistDetails from "./slices/playlistDetailsSlice"; import themeDetails from "./slices/themeDetailsSlice"; import userInfo from "./slices/userInfoSlice"; import statistics from "./slices/statisticsSlice"; @@ -52,40 +53,41 @@ const themesPersistConfig = { key: "themes", storage, whitelist: ["columns"] }; // form reducer and all other reducers used in this app const reducers = combineReducers({ - tableFilters, - tableFilterProfiles: persistReducer(tableFilterProfilesPersistConfig, tableFilterProfiles), - events: persistReducer(eventsPersistConfig, events), - series: persistReducer(seriesPersistConfig, series), - playlists: persistReducer(playlistPersistConfig, playlists), - table: persistReducer(tablePersistConfig, table), - recordings: persistReducer(recordingsPersistConfig, recordings), - jobs: persistReducer(jobsPersistConfig, jobs), - servers: persistReducer(serversPersistConfig, servers), - services: persistReducer(servicesPersistConfig, services), - users: persistReducer(usersPersistConfig, users), - groups: persistReducer(groupsPersistConfig, groups), - acls: persistReducer(aclsPersistConfig, acls), - themes: persistReducer(themesPersistConfig, themes), - health, - notifications, - workflows, - eventDetails, - themeDetails, - seriesDetails, - recordingDetails, - userDetails, - groupDetails, - aclDetails, - userInfo, - statistics, + tableFilters, + tableFilterProfiles: persistReducer(tableFilterProfilesPersistConfig, tableFilterProfiles), + events: persistReducer(eventsPersistConfig, events), + series: persistReducer(seriesPersistConfig, series), + playlists: persistReducer(playlistPersistConfig, playlists), + table: persistReducer(tablePersistConfig, table), + recordings: persistReducer(recordingsPersistConfig, recordings), + jobs: persistReducer(jobsPersistConfig, jobs), + servers: persistReducer(serversPersistConfig, servers), + services: persistReducer(servicesPersistConfig, services), + users: persistReducer(usersPersistConfig, users), + groups: persistReducer(groupsPersistConfig, groups), + acls: persistReducer(aclsPersistConfig, acls), + themes: persistReducer(themesPersistConfig, themes), + health, + notifications, + workflows, + eventDetails, + playlistDetails, + themeDetails, + seriesDetails, + recordingDetails, + userDetails, + groupDetails, + aclDetails, + userInfo, + statistics, }); // Configuration for persisting store const persistConfig = { - key: "root", - storage, - stateReconciler: autoMergeLevel2, - whitelist: ["tableFilters"], + key: "root", + storage, + stateReconciler: autoMergeLevel2, + whitelist: ["tableFilters"], }; const persistedReducer = persistReducer>(persistConfig, reducers); From f422c67ec3137dd3e76c4e995e084e771e576d6f Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 25 Feb 2026 18:05:46 +0100 Subject: [PATCH 3/7] Prevent layout shift in `SaveEditFooter` I think this project should really get away from scss or at least reconsider and rewrite some styles. Why are there `first-child` selectors with `float: right`? That means you have to write column layouts all backwards, which is unintuitive and confusing. Anyway. This just removes some styles/rules from the `SaveEditFooter` component that would not only hide but completely remove certain buttons from the dom while they're disabled. That can lead to layout shifts and confused margins whenever they do pop in. So with this change, they are now always visible and simply use disabled styles and behaviour when appropriate. --- src/components/shared/SaveEditFooter.tsx | 76 +++++++++++++----------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/components/shared/SaveEditFooter.tsx b/src/components/shared/SaveEditFooter.tsx index c19f645aba..d93bb6b8f8 100644 --- a/src/components/shared/SaveEditFooter.tsx +++ b/src/components/shared/SaveEditFooter.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { ParseKeys } from "i18next"; import BaseButton from "./BaseButton"; + type SaveEditFooterProps = { active: boolean; reset: () => void; @@ -16,43 +17,46 @@ type SaveEditFooterProps = { } export const SaveEditFooter: React.FC = ({ - active, - reset, - submit, - isValid, - customSaveButtonText, - additionalButton, + active, + reset, + submit, + isValid, + customSaveButtonText, + additionalButton, }) => { - const { t } = useTranslation(); + const { t } = useTranslation(); - const saveButtonText = customSaveButtonText || "SAVE"; + const saveButtonText = customSaveButtonText || "SAVE"; + const disabled = !(isValid && active); - return
- {t(saveButtonText)} - {additionalButton && ( - {t(additionalButton.label)} - )} - {active && isValid && ( - {t("CANCEL")} - )} -
; + return
+ + {t(saveButtonText)} + + {additionalButton && ( + + {t(additionalButton.label)} + + )} + + {t("CANCEL")} + +
; }; From 4f468713a9343c4e22560382fd9d5ad2646af7be Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 25 Feb 2026 18:52:33 +0100 Subject: [PATCH 4/7] Add modal/API for playlist creation/deletion Like most of these playlist related additions, this is heavily based on the structure and components that are used for events and series. I'm sure there is a lot of potential for deduplication, but I don't want to touch too many non-playlist files. I think that should be done in a dedicated PR. --- src/components/events/Playlists.tsx | 41 ++-- .../events/partials/PlaylistActionsCell.tsx | 12 +- .../partials/wizards/NewPlaylistSummary.tsx | 50 +++++ .../partials/wizards/NewPlaylistWizard.tsx | 191 ++++++++++++++++++ src/components/shared/ConfirmModal.tsx | 4 +- src/components/shared/NewResourceModal.tsx | 10 +- src/configs/modalConfig.ts | 17 ++ .../adminui/languages/lang-en_US.json | 18 ++ src/slices/playlistSlice.ts | 110 +++++++++- src/utils/validate.ts | 8 + 10 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 src/components/events/partials/wizards/NewPlaylistSummary.tsx create mode 100644 src/components/events/partials/wizards/NewPlaylistWizard.tsx diff --git a/src/components/events/Playlists.tsx b/src/components/events/Playlists.tsx index dd36a4ec38..c79c023d59 100644 --- a/src/components/events/Playlists.tsx +++ b/src/components/events/Playlists.tsx @@ -5,22 +5,39 @@ import { getTotalPlaylists } from "../../selectors/playlistSelectors"; import { eventsLinks } from "./partials/EventsNavigation"; import { playlistsTemplateMap } from "../../configs/tableConfigs/playlistsTableMap"; import PlaylistDetailsModal from "./partials/modals/PlaylistDetailsModal"; +import { useAppDispatch } from "../../store"; +import { fetchAclDefaults } from "../../slices/aclSlice"; /** * This component renders the table view of playlists */ -const Playlists = () => <> - - -; +const Playlists = () => { + const dispatch = useAppDispatch(); + + const onNewPlaylistModal = async () => { + await dispatch(fetchAclDefaults()); + }; + + return <> + + + + ; +}; export default Playlists; diff --git a/src/components/events/partials/PlaylistActionsCell.tsx b/src/components/events/partials/PlaylistActionsCell.tsx index a285a03601..539ba465e8 100644 --- a/src/components/events/partials/PlaylistActionsCell.tsx +++ b/src/components/events/partials/PlaylistActionsCell.tsx @@ -2,9 +2,10 @@ import { LuFileText } from "react-icons/lu"; import { fetchPlaylistDetails, openModal } from "../../../slices/playlistDetailsSlice"; import { useAppDispatch } from "../../../store"; -import { Playlist } from "../../../slices/playlistSlice"; +import { deletePlaylist, Playlist } from "../../../slices/playlistSlice"; import ButtonLikeAnchor from "../../shared/ButtonLikeAnchor"; import { PlaylistDetailsPage } from "./modals/PlaylistDetails"; +import { ActionCellDelete } from "../../shared/ActionCellDelete"; /** @@ -32,6 +33,15 @@ const PlaylistActionsCell = ({ > + + {/* delete playlist */} + dispatch(deletePlaylist(id))} + /> ; }; diff --git a/src/components/events/partials/wizards/NewPlaylistSummary.tsx b/src/components/events/partials/wizards/NewPlaylistSummary.tsx new file mode 100644 index 0000000000..c58c91d9af --- /dev/null +++ b/src/components/events/partials/wizards/NewPlaylistSummary.tsx @@ -0,0 +1,50 @@ +import { FormikProps } from "formik"; + +import MetadataSummaryTable from "./summaryTables/MetadataSummaryTable"; +import AccessSummaryTable from "./summaryTables/AccessSummaryTable"; +import WizardNavigationButtons from "../../../shared/wizard/WizardNavigationButtons"; +import { TransformedAcl } from "../../../../slices/aclDetailsSlice"; +import { MetadataCatalog } from "../../../../slices/eventSlice"; +import ModalContentTable from "../../../shared/modals/ModalContentTable"; + + +/** + * Summary page for new playlists in new playlist wizard. + */ +interface RequiredFormProps { + policies: TransformedAcl[], + metadata: { [key: string]: unknown } +} + +const NewPlaylistSummary = ({ + formik, + previousPage, + metadataFields, +}: { + formik: FormikProps, + previousPage: (values: T, twoPagesBack?: boolean) => void, + metadataFields: MetadataCatalog, +}) => <> + + + + + + + + ; + + +export default NewPlaylistSummary; diff --git a/src/components/events/partials/wizards/NewPlaylistWizard.tsx b/src/components/events/partials/wizards/NewPlaylistWizard.tsx new file mode 100644 index 0000000000..7076d34a1e --- /dev/null +++ b/src/components/events/partials/wizards/NewPlaylistWizard.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect } from "react"; +import { Formik } from "formik"; + +import NewMetadataCommonPage from "../ModalTabsAndPages/NewMetadataCommonPage"; +import NewAccessPage from "../ModalTabsAndPages/NewAccessPage"; +import NewPlaylistSummary from "./NewPlaylistSummary"; +import WizardStepper, { WizardStep } from "../../../shared/wizard/WizardStepper"; +import { initialFormValuesNewPlaylist } from "../../../../configs/modalConfig"; +import { MetadataSchema, NewPlaylistSchema } from "../../../../utils/validate"; +import { getInitialMetadataFieldValues } from "../../../../utils/resourceUtils"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { getNewPlaylistMetadataFields, postNewPlaylist } from "../../../../slices/playlistSlice"; +import { TransformedAcl } from "../../../../slices/aclDetailsSlice"; +import { removeNotificationWizardForm } from "../../../../slices/notificationSlice"; +import { getUserInformation } from "../../../../selectors/userInfoSelectors"; +import { getAclDefaultActions, getAclDefaultTemplate } from "../../../../selectors/aclSelectors"; +import { AclTemplate } from "../../../../slices/aclSlice"; +import { UserInfoState } from "../../../../slices/userInfoSlice"; + + +/** + * Manages tabs of the new playlist wizard and submission of form values. + */ +const NewPlaylistWizard = ({ + close, +}: { + close: () => void +}) => { + const dispatch = useAppDispatch(); + + const user = useAppSelector(state => getUserInformation(state)); + const metadataFields = getNewPlaylistMetadataFields(user.user.name); + const aclDefaultActions = useAppSelector(state => getAclDefaultActions(state)); + const aclDefaultTemplate = useAppSelector(state => getAclDefaultTemplate(state)); + + useEffect(() => { + dispatch(removeNotificationWizardForm()); + }, [dispatch]); + + const initialValues = getInitialValues( + metadataFields, + user, + aclDefaultActions, + aclDefaultTemplate, + ); + + const [page, setPage] = useState(0); + const [pageCompleted, setPageCompleted] = useState<{ [key: number]: boolean }>({}); + + type StepName = "metadata" | "access" | "summary"; + type Step = WizardStep & { + name: StepName, + } + + const steps: Step[] = [ + { + translation: "EVENTS.PLAYLISTS.NEW.METADATA.CAPTION", + name: "metadata", + }, + { + translation: "EVENTS.PLAYLISTS.NEW.ACCESS.CAPTION", + name: "access", + }, + { + translation: "EVENTS.PLAYLISTS.NEW.SUMMARY.CAPTION", + name: "summary", + }, + ]; + + // Validation schema of current page + let currentValidationSchema; + if (page === 0) { + currentValidationSchema = MetadataSchema(metadataFields); + } else { + currentValidationSchema = NewPlaylistSchema[steps[page].name]; + } + + const nextPage = () => { + const updatedPageCompleted = pageCompleted; + updatedPageCompleted[page] = true; + setPageCompleted(updatedPageCompleted); + setPage(page + 1); + }; + + const previousPage = () => { + setPage(page - 1); + }; + + const handleSubmit = ( + values: { + metadata: { [key: string]: unknown }, + policies: TransformedAcl[], + }, + ) => { + // Extract metadata field values from the formik values + const metadataPrefix = metadataFields.flavor + "_"; + const title = (values.metadata[metadataPrefix + "title"] as string) || ""; + const description = (values.metadata[metadataPrefix + "description"] as string) || ""; + const creator = (values.metadata[metadataPrefix + "creator"] as string) || ""; + + dispatch(postNewPlaylist({ + values, + metadataFields: { title, description, creator }, + })); + + close(); + }; + + return <> + handleSubmit(values)} + > + {formik => <> + + +
+ {steps[page].name === "metadata" && ( + + )} + + {steps[page].name === "access" && ( + + )} + + {steps[page].name === "summary" && ( + + )} +
+ } +
+ ; +}; + +const getInitialValues = ( + metadataFields: ReturnType, + user: UserInfoState, + aclDefaultActions: string[], + aclDefaultTemplate?: AclTemplate, +) => { + const initialValues = { ...initialFormValuesNewPlaylist }; + + const metadataInitialValues = getInitialMetadataFieldValues(metadataFields); + initialValues.metadata = { ...metadataInitialValues }; + + initialValues["policies"] = [ + { + role: user.userRole, + read: true, + write: true, + actions: aclDefaultActions ? aclDefaultActions : [], + user: user.user, + }, + ]; + + if (aclDefaultTemplate) { + initialValues["aclTemplate"] = aclDefaultTemplate.id.toString(); + initialValues["policies"] = [...aclDefaultTemplate.acl, ...initialValues["policies"]]; + } + + return initialValues; +}; + +export default NewPlaylistWizard; diff --git a/src/components/shared/ConfirmModal.tsx b/src/components/shared/ConfirmModal.tsx index 200bcf6e46..cb561d266f 100644 --- a/src/components/shared/ConfirmModal.tsx +++ b/src/components/shared/ConfirmModal.tsx @@ -5,7 +5,9 @@ import { NotificationComponent } from "./Notifications"; import { ParseKeys } from "i18next"; import BaseButton from "./BaseButton"; -export type ResourceType = "EVENT" | "SERIES" | "LOCATION" | "USER" | "GROUP" | "ACL" | "THEME" | "TOBIRA_PATH"; +export type ResourceType = + "EVENT" | "SERIES" | "PLAYLIST" | "LOCATION" | "USER" | + "GROUP" | "ACL" | "THEME" | "TOBIRA_PATH"; const ConfirmModal = ({ close, diff --git a/src/components/shared/NewResourceModal.tsx b/src/components/shared/NewResourceModal.tsx index 488931b7f1..5412bb1666 100644 --- a/src/components/shared/NewResourceModal.tsx +++ b/src/components/shared/NewResourceModal.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import NewEventWizard from "../events/partials/wizards/NewEventWizard"; import NewSeriesWizard from "../events/partials/wizards/NewSeriesWizard"; +import NewPlaylistWizard from "../events/partials/wizards/NewPlaylistWizard"; import NewThemeWizard from "../configuration/partials/wizard/NewThemeWizard"; import NewAclWizard from "../users/partials/wizard/NewAclWizard"; import NewGroupWizard from "../users/partials/wizard/NewGroupWizard"; @@ -14,6 +15,7 @@ import { Modal, ModalHandle } from "./modals/Modal"; export type NewResource = | "events" | "series" + | "playlists" | "user" | "group" | "acl" @@ -25,7 +27,7 @@ const NewResourceModal = ({ modalRef, }: { handleClose: () => void; - resource: "events" | "series" | "user" | "group" | "acl" | "themes"; + resource: NewResource; modalRef: React.RefObject; }) => { const { t } = useTranslation(); @@ -40,6 +42,8 @@ const NewResourceModal = ({ return t("EVENTS.EVENTS.NEW.CAPTION"); case "series": return t("EVENTS.SERIES.NEW.CAPTION"); + case "playlists": + return t("EVENTS.PLAYLISTS.NEW.CAPTION"); case "themes": return t("CONFIGURATION.THEMES.DETAILS.NEWCAPTION"); case "acl": @@ -66,6 +70,10 @@ const NewResourceModal = ({ // New Series Wizard )} + {resource === "playlists" && ( + // New Playlist Wizard + + )} {resource === "themes" && ( // New Theme Wizard diff --git a/src/configs/modalConfig.ts b/src/configs/modalConfig.ts index b1c9cf6ed9..4f56247dcc 100644 --- a/src/configs/modalConfig.ts +++ b/src/configs/modalConfig.ts @@ -122,6 +122,23 @@ export const initialFormValuesNewSeries: { metadata: {}, }; + +export const initialFormValuesNewPlaylist: { + policies: TransformedAcl[], + aclTemplate?: string, + metadata: { [key: string]: unknown } +} = { + policies: [ + { + role: "ROLE_USER_ADMIN", + read: true, + write: true, + actions: [], + }, + ], + metadata: {}, +}; + // All fields for new theme form that are fix and not depending on response of backend // InitialValues of Formik form (others computed dynamically depending on responses from backend) export const initialFormValuesNewThemes = { 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 608d53c19f..ba95d5cc35 100644 --- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json +++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json @@ -117,6 +117,7 @@ "UNKNOWN": "The following element will be deleted", "EVENT": "The following event will be deleted", "SERIES": "The following series will be deleted", + "PLAYLIST": "The following playlist will be deleted", "ACL": "The following ACL will be deleted", "GROUP": "The following group will be deleted", "USER": "The following user will be deleted", @@ -179,6 +180,10 @@ "GROUP_NOT_DELETED": "The group could not be deleted", "SERIES_ADDED": "The series has been created", "SERIES_NOT_SAVED": "The series could not be saved", + "PLAYLIST_ADDED": "The playlist has been created", + "PLAYLIST_NOT_SAVED": "The playlist could not be created", + "PLAYLIST_DELETED": "The playlist has been deleted", + "PLAYLIST_NOT_DELETED": "The playlist could not be deleted", "SERIES_PATH_UPDATED": "The series path has been updated", "SERIES_PATH_REMOVED": "The series path has been removed", "SERIES_PATH_NOT_UPDATED": "The series path could not be updated", @@ -634,6 +639,7 @@ "UPLOAD": "Upload", "ADD_SERIES": "Add series", "ADD_EVENT": "Add event", + "ADD_PLAYLIST": "Add playlist", "TABLE": { "CAPTION": "Events", "TITLE": "Title", @@ -1292,6 +1298,18 @@ } }, "PLAYLISTS": { + "NEW": { + "CAPTION": "Add playlist", + "METADATA": { + "CAPTION": "Metadata" + }, + "ACCESS": { + "CAPTION": "Access policy" + }, + "SUMMARY": { + "CAPTION": "Summary" + } + }, "TABLE": { "CAPTION": "Playlists", "TITLE": "Title", diff --git a/src/slices/playlistSlice.ts b/src/slices/playlistSlice.ts index 473af4cc7b..e8c68f4d62 100644 --- a/src/slices/playlistSlice.ts +++ b/src/slices/playlistSlice.ts @@ -3,8 +3,49 @@ import axios from "axios"; import { TableConfig } from "../configs/tableConfigs/aclsTableConfig"; import { createAppAsyncThunk } from "../createAsyncThunkWithTypes"; -import { getURLParams } from "../utils/resourceUtils"; +import { getURLParams, prepareAccessPolicyRulesForPost } from "../utils/resourceUtils"; import { playlistsTableConfig } from "../configs/tableConfigs/playlistsTableConfig"; +import { addNotification } from "./notificationSlice"; +import { TransformedAcl } from "./aclDetailsSlice"; +import { MetadataCatalog } from "./eventSlice"; +import { AppThunk } from "../store"; + +/** + * Build the metadata catalog for new playlist creation. + * Unlike series/events, playlists don't have a backend metadata endpoint — + * the fields are derived from the playlist model itself. + * The creator field is read-only and pre-filled with the current user's name. + */ +export const getNewPlaylistMetadataFields = (creatorName: string): MetadataCatalog => ({ + title: "EVENTS.PLAYLISTS.NEW.METADATA.CAPTION", + flavor: "playlist/details", + fields: [ + { + id: "title", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.TITLE", + readOnly: false, + required: true, + type: "text", + value: "", + }, + { + id: "description", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.DESCRIPTION", + readOnly: false, + required: false, + type: "text_long", + value: "", + }, + { + id: "creator", + label: "EVENTS.PLAYLISTS.DETAILS.METADATA.CREATOR", + readOnly: true, + required: false, + type: "text", + value: creatorName, + }, + ], +}); export type Playlist = { @@ -45,7 +86,6 @@ const initialColumns = playlistsTableConfig.columns.map(column => ({ deactivated: false, })); - const initialState: PlaylistState = { status: "uninitialized", error: null, @@ -66,7 +106,6 @@ type FetchPlaylists = { results: Playlist[], }; - export const fetchPlaylists = createAppAsyncThunk("playlists/fetchPlaylists", async (_, { getState }) => { const state = getState(); const params = getURLParams(state, "playlists"); @@ -76,6 +115,66 @@ export const fetchPlaylists = createAppAsyncThunk("playlists/fetchPlaylists", as return res.data; }); + +export const postNewPlaylist = (params: { + values: { + policies: TransformedAcl[], + metadata: { [key: string]: unknown }, + }, + metadataFields: { title: string, description: string, creator: string }, +}): AppThunk => dispatch => { + const { values, metadataFields } = params; + + // Build payload from form values + const playlist: Record = { + title: metadataFields.title, + description: metadataFields.description, + creator: metadataFields.creator, + entries: [], + }; + + // Build ACL + const access = prepareAccessPolicyRulesForPost(values.policies); + const accessControlEntries: { allow: boolean, role: string, action: string }[] = []; + if (access.acl?.ace) { + for (const ace of access.acl.ace) { + accessControlEntries.push({ allow: ace.allow, role: ace.role, action: ace.action }); + } + } + playlist.accessControlEntries = accessControlEntries; + + const data = new URLSearchParams(); + data.append("playlist", JSON.stringify(playlist)); + + axios + .post("/admin-ng/playlists", data.toString(), { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }) + .then(response => { + console.info(response); + dispatch(addNotification({ type: "success", key: "PLAYLIST_ADDED" })); + }) + .catch(response => { + console.error(response); + dispatch(addNotification({ type: "error", key: "PLAYLIST_NOT_SAVED" })); + }); +}; + + +export const deletePlaylist = (id: Playlist["id"]): AppThunk => dispatch => { + axios + .delete(`/admin-ng/playlists/${id}`) + .then(res => { + console.info(res); + dispatch(addNotification({ type: "success", key: "PLAYLIST_DELETED" })); + }) + .catch(res => { + console.error(res); + dispatch(addNotification({ type: "error", key: "PLAYLIST_NOT_DELETED" })); + }); +}; + + const playlistSlice = createSlice({ name: "playlist", initialState, @@ -106,9 +205,6 @@ const playlistSlice = createSlice({ }, }); -export const { - setPlaylistColumns, -} = playlistSlice.actions; +export const { setPlaylistColumns } = playlistSlice.actions; export default playlistSlice.reducer; - diff --git a/src/utils/validate.ts b/src/utils/validate.ts index 9ef13471b9..e0d53da092 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -164,6 +164,14 @@ export const NewSeriesSchema = { "summary": Yup.object().shape({}), }; +// Validation Schema used in new playlist wizard (each step has its own yup validation object) +export const NewPlaylistSchema: Record> = { + // For metadata validation see MetadataSchema + "metadata": Yup.object().shape({}), + "access": Yup.object().shape({}), + "summary": Yup.object().shape({}), +}; + // Validation Schema used in new themes wizard (each step has its own yup validation object) export const NewThemeSchema = { "generalForm": Yup.object().shape({ From 77d39efed32fd80b6223f789c70eecc78b154aae Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Mon, 16 Mar 2026 18:14:17 +0100 Subject: [PATCH 5/7] Add playlist content management This let users add and removes entries to and from their playlists using a GUI. The UI reuses the drag n drop approach that is present for editing table view columns. Entries are shown with some additional metadata, so they are easier to tell apart if some names are repeated (hopefully). --- .../NewPlaylistEntriesPage.tsx | 50 +++ .../PlaylistDetailsEntriesTab.tsx | 67 ++++ .../PlaylistEntriesEditor.tsx | 295 ++++++++++++++++++ .../partials/modals/PlaylistDetails.tsx | 20 +- .../partials/modals/PlaylistDetailsModal.tsx | 16 +- .../partials/wizards/NewPlaylistSummary.tsx | 29 +- .../partials/wizards/NewPlaylistWizard.tsx | 17 +- src/configs/modalConfig.ts | 5 +- .../adminui/languages/lang-en_US.json | 13 + src/selectors/playlistDetailsSelectors.ts | 2 + src/slices/playlistDetailsSlice.ts | 63 +++- src/slices/playlistSlice.ts | 8 +- src/styles/views/_views-config.scss | 1 + src/styles/views/modals/_playlist.scss | 104 ++++++ src/utils/validate.ts | 1 + 15 files changed, 679 insertions(+), 12 deletions(-) create mode 100644 src/components/events/partials/ModalTabsAndPages/NewPlaylistEntriesPage.tsx create mode 100644 src/components/events/partials/ModalTabsAndPages/PlaylistDetailsEntriesTab.tsx create mode 100644 src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx create mode 100644 src/styles/views/modals/_playlist.scss diff --git a/src/components/events/partials/ModalTabsAndPages/NewPlaylistEntriesPage.tsx b/src/components/events/partials/ModalTabsAndPages/NewPlaylistEntriesPage.tsx new file mode 100644 index 0000000000..536c5a1bdb --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/NewPlaylistEntriesPage.tsx @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import { FormikProps } from "formik"; + +import { PlaylistEntry } from "../../../../slices/playlistDetailsSlice"; +import WizardNavigationButtons from "../../../shared/wizard/WizardNavigationButtons"; +import PlaylistEntriesEditor from "./PlaylistEntriesEditor"; +import ModalContentTable from "../../../shared/modals/ModalContentTable"; + + +interface RequiredFormProps { + entries: PlaylistEntry[], +} + +/** + * Wizard page for adding entries to a new playlist. + * Stores entries in Formik values rather than Redux state. + */ +const NewPlaylistEntriesPage = ({ + formik, + nextPage, + previousPage, +}: { + formik: FormikProps, + nextPage: (values: T) => void, + previousPage: (values: T) => void, +}) => { + const entries = formik.values.entries; + + const setEntries = useCallback((updated: PlaylistEntry[]) => { + void formik.setFieldValue("entries", updated); + }, [formik]); + + return <> + + + + + + ; +}; + +export default NewPlaylistEntriesPage; diff --git a/src/components/events/partials/ModalTabsAndPages/PlaylistDetailsEntriesTab.tsx b/src/components/events/partials/ModalTabsAndPages/PlaylistDetailsEntriesTab.tsx new file mode 100644 index 0000000000..3bfc6513aa --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/PlaylistDetailsEntriesTab.tsx @@ -0,0 +1,67 @@ +import { useCallback } from "react"; + +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { + getPlaylistDetailsEntries, + getPlaylistDetailsEntriesChanged, +} from "../../../../selectors/playlistDetailsSelectors"; +import { + PlaylistEntry, + fetchPlaylistDetails, + setPlaylistDetailsEntries, + setPlaylistEntriesChanged, + updatePlaylistEntries, +} from "../../../../slices/playlistDetailsSlice"; +import { SaveEditFooter } from "../../../shared/SaveEditFooter"; +import PlaylistEntriesEditor from "./PlaylistEntriesEditor"; +import ModalContentTable from "../../../shared/modals/ModalContentTable"; + + +/* Entries management for existing playlist details */ +const PlaylistDetailsEntriesTab = ({ + playlistId, +}: { + playlistId: string, +}) => { + const dispatch = useAppDispatch(); + + const entries = useAppSelector( + state => getPlaylistDetailsEntries(state), + ); + + const entriesChanged = useAppSelector( + state => getPlaylistDetailsEntriesChanged(state), + ); + + const setEntries = useCallback((updated: PlaylistEntry[]) => { + dispatch(setPlaylistDetailsEntries(updated)); + dispatch(setPlaylistEntriesChanged(true)); + }, [dispatch]); + + const saveEntries = () => { + void dispatch(updatePlaylistEntries({ id: playlistId, entries })); + }; + + const resetEntries = () => { + void dispatch(fetchPlaylistDetails(playlistId)); + }; + + return <> + + + + + + ; +}; + +export default PlaylistDetailsEntriesTab; diff --git a/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx b/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx new file mode 100644 index 0000000000..92d1cb2e33 --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx @@ -0,0 +1,295 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import axios from "axios"; +import { arrayMoveImmutable } from "array-move"; +import { DragDropContext, Droppable, Draggable, OnDragEndResponder } from "@hello-pangea/dnd"; +import { LuCircleX, LuExternalLink, LuGrip } from "react-icons/lu"; + +import { getSourceURL } from "../../../../utils/embeddedCodeUtils"; +import { PlaylistEntry } from "../../../../slices/playlistDetailsSlice"; +import Notifications from "../../../shared/Notifications"; +import DropDown from "../../../shared/DropDown"; +import ButtonLikeAnchor from "../../../shared/ButtonLikeAnchor"; + + +export type EventResult = { + id: string, + title: string, + start_date: string, + series?: { id: string, title: string }, + presenters: string[], +}; + +type EventSearchResult = { + results: EventResult[], + total: number, +}; + +/** + * Search events by text filter and cache the results. + * IDs already in the playlist are excluded. + */ +const searchEvents = async ( + inputValue: string, + excludeIds: Set, + metadataCache: React.RefObject>, + setNoAvailableEvents: (empty: boolean) => void, +): Promise<{ label: string, value: string }[]> => { + const params: Record = { + limit: 20, + offset: 0, + }; + + if (inputValue) { + params.filter = `textFilter:${inputValue}`; + } + + const res = await axios.get( + "/admin-ng/event/events.json", + { params }, + ); + + const filtered = res.data.results.filter(e => !excludeIds.has(e.id)); + for (const e of filtered) { + metadataCache.current.set(e.id, e); + } + + if (!inputValue) { + setNoAvailableEvents(filtered.length === 0); + } + + return filtered.map(e => ({ + label: e.title, + value: e.id, + })); +}; + +const EntryMeta = ({ entry }: { entry: PlaylistEntry }) => { + const { t } = useTranslation(); + + const hasMeta = entry.date || entry.series + || (entry.presenters && entry.presenters.length > 0); + + if (!hasMeta) { + return null; + } + + return
+ {entry.date && ( + + + {t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.DATE_LABEL")} + + {new Date(entry.date).toLocaleDateString()} + + )} + + {entry.series && ( + + + {t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.SERIES_LABEL")} + + {entry.series} + + )} + + {entry.presenters && entry.presenters.length > 0 && ( + + + {t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.PRESENTERS_LABEL")} + + {entry.presenters.join(", ")} + + )} +
; +}; + + +const PlaylistEntriesEditor = ({ + entries, + setEntries, + showEngageLinks, +}: { + entries: PlaylistEntry[], + setEntries: (updated: PlaylistEntry[]) => void, + showEngageLinks?: boolean, +}) => { + const { t } = useTranslation(); + const metadataCache = useRef(new Map()); + const [noAvailableEvents, setNoAvailableEvents] = useState(false); + const [engageUrl, setEngageUrl] = useState(""); + + useEffect(() => { + if (showEngageLinks) { + void getSourceURL().then(url => setEngageUrl(url)); + } + }, [showEngageLinks]); + + const entryIds = new Set(entries.map(e => e.contentId)); + + const fetchFilteredEvents = (input: string) => searchEvents( + input, entryIds, metadataCache, setNoAvailableEvents, + ); + + const addEntry = (eventId: string, title: string) => { + if (entries.some(e => e.contentId === eventId)) { + return; + } + + const meta = metadataCache.current.get(eventId); + + const newEntry: PlaylistEntry = { + contentId: eventId, + type: "EVENT", + title, + date: meta?.start_date, + series: meta?.series?.title, + presenters: meta?.presenters, + }; + + setEntries([...entries, newEntry]); + }; + + const removeEntry = (index: number) => { + setEntries(entries.filter((_, i) => i !== index)); + }; + + const onDragEnd: OnDragEndResponder = result => { + const destination = result.destination; + if (!destination) { + return; + } + setEntries( + arrayMoveImmutable(entries, result.source.index, destination.index), + ); + }; + + return <> + + + {/* Dropdown to add entries */} +
+
+

{t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.AVAILABLE")}

+
+ + + + + + + +
+ { + if (option && option.value) { + addEntry(option.value, option.label); + } + }} + placeholder={t( + noAvailableEvents + ? "EVENTS.PLAYLISTS.DETAILS.ENTRIES.NO_AVAILABLE" + : "EVENTS.PLAYLISTS.DETAILS.ENTRIES.SEARCH_PLACEHOLDER", + )} + disabled={noAvailableEvents} + fetchOptions={fetchFilteredEvents} + skipTranslate + customCSS={{ width: "100%" }} + /> +
+
+ + {/* Entry list */} +
+
+

+ {t("EVENTS.PLAYLISTS.DETAILS.TABS.ENTRIES")} + {entries.length > 0 && ( + + {" "}({entries.length}) + + )} +

+
+ + {entries.length === 0 ? ( +

+ {t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.EMPTY")} +

+ ) : ( +
+ + + {provided => ( +
+ {entries.map((entry, index) => ( + + {provided => ( +
+ +
+
+ {entry.title} +
+ +
+ {showEngageLinks && engageUrl && ( + e.stopPropagation()} + data-tooltip-id="my-tooltip" + data-tooltip-content={t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.OPEN_PLAYER")} + > + + + )} + removeEntry(index)} + tooltipText="EVENTS.PLAYLISTS.DETAILS.ENTRIES.REMOVE" + aria-label={t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.REMOVE")} + > + + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
+ )} +
+ ; +}; + +export default PlaylistEntriesEditor; diff --git a/src/components/events/partials/modals/PlaylistDetails.tsx b/src/components/events/partials/modals/PlaylistDetails.tsx index 4f12d0761b..c4da2154b9 100644 --- a/src/components/events/partials/modals/PlaylistDetails.tsx +++ b/src/components/events/partials/modals/PlaylistDetails.tsx @@ -7,15 +7,20 @@ import { ParseKeys } from "i18next"; import { getUserInformation } from "../../../../selectors/userInfoSelectors"; import { confirmUnsaved, hasAccess } from "../../../../utils/utils"; import { useAppSelector } from "../../../../store"; -import { getPlaylistDetailsMetadata } from "../../../../selectors/playlistDetailsSelectors"; +import { + getPlaylistDetailsEntriesChanged, + getPlaylistDetailsMetadata, +} from "../../../../selectors/playlistDetailsSelectors"; import { updatePlaylistMetadata } from "../../../../slices/playlistDetailsSlice"; import ButtonLikeAnchor from "../../../shared/ButtonLikeAnchor"; import DetailsMetadataTab, { MetadataValues } from "../ModalTabsAndPages/DetailsMetadataTab"; import PlaylistDetailsAccessTab from "../ModalTabsAndPages/PlaylistDetailsAccessTab"; +import PlaylistDetailsEntriesTab from "../ModalTabsAndPages/PlaylistDetailsEntriesTab"; export enum PlaylistDetailsPage { Metadata, + Entries, AccessPolicy, } @@ -37,6 +42,7 @@ const PlaylistDetails = ({ const metadata = useAppSelector(state => getPlaylistDetailsMetadata(state)); const user = useAppSelector(state => getUserInformation(state)); + const entriesChanged = useAppSelector(state => getPlaylistDetailsEntriesChanged(state)); const [page, setPage] = useState(0); @@ -50,6 +56,11 @@ const PlaylistDetails = ({ accessRole: "ROLE_UI_PLAYLISTS_DETAILS_METADATA_VIEW", name: "metadata", }, + { + tabNameTranslation: "EVENTS.PLAYLISTS.DETAILS.TABS.ENTRIES", + accessRole: "ROLE_UI_PLAYLISTS_DETAILS_METADATA_VIEW", + name: "entries", + }, { tabNameTranslation: "EVENTS.PLAYLISTS.DETAILS.TABS.PERMISSIONS", accessRole: "ROLE_UI_PLAYLISTS_DETAILS_ACL_VIEW", @@ -58,7 +69,7 @@ const PlaylistDetails = ({ ]; const openTab = (tabNr: number) => { - let isUnsavedChanges = policyChanged; + let isUnsavedChanges = policyChanged || entriesChanged; if (formikRef.current?.dirty) { isUnsavedChanges = true; } @@ -94,7 +105,10 @@ const PlaylistDetails = ({ header={tabs[page].tabNameTranslation} /> )} - {page === 1 && } + {page === 2 && { const displayPlaylistDetailsModal = useAppSelector(state => showModal(state)); const playlist = useAppSelector(state => getModalPlaylist(state))!; + const entriesChanged = useAppSelector(state => getPlaylistDetailsEntriesChanged(state)); const hideModal = () => { dispatch(setModalPlaylist(null)); @@ -31,13 +40,14 @@ const PlaylistDetailsModal = () => { }; const close = () => { - let isUnsavedChanges = policyChanged; + let isUnsavedChanges = policyChanged || entriesChanged; if (formikRef.current?.dirty) { isUnsavedChanges = true; } if (!isUnsavedChanges || confirmUnsaved(t)) { setPolicyChanged(false); + dispatch(setPlaylistEntriesChanged(false)); dispatch(removeNotificationWizardForm()); hideModal(); return true; diff --git a/src/components/events/partials/wizards/NewPlaylistSummary.tsx b/src/components/events/partials/wizards/NewPlaylistSummary.tsx index c58c91d9af..f158f40bce 100644 --- a/src/components/events/partials/wizards/NewPlaylistSummary.tsx +++ b/src/components/events/partials/wizards/NewPlaylistSummary.tsx @@ -1,9 +1,11 @@ +import { useTranslation } from "react-i18next"; import { FormikProps } from "formik"; import MetadataSummaryTable from "./summaryTables/MetadataSummaryTable"; import AccessSummaryTable from "./summaryTables/AccessSummaryTable"; import WizardNavigationButtons from "../../../shared/wizard/WizardNavigationButtons"; import { TransformedAcl } from "../../../../slices/aclDetailsSlice"; +import { PlaylistEntry } from "../../../../slices/playlistDetailsSlice"; import { MetadataCatalog } from "../../../../slices/eventSlice"; import ModalContentTable from "../../../shared/modals/ModalContentTable"; @@ -13,7 +15,8 @@ import ModalContentTable from "../../../shared/modals/ModalContentTable"; */ interface RequiredFormProps { policies: TransformedAcl[], - metadata: { [key: string]: unknown } + metadata: { [key: string]: unknown }, + entries: PlaylistEntry[], } const NewPlaylistSummary = ({ @@ -24,7 +27,10 @@ const NewPlaylistSummary = ({ formik: FormikProps, previousPage: (values: T, twoPagesBack?: boolean) => void, metadataFields: MetadataCatalog, -}) => <> +}) => { + const { t } = useTranslation(); + + return <> ({ header={"EVENTS.PLAYLISTS.NEW.METADATA.CAPTION"} /> + {/* Summary entries */} + {formik.values.entries.length > 0 && ( +
+
+ {t("EVENTS.PLAYLISTS.DETAILS.TABS.ENTRIES")} +
+ + + {formik.values.entries.map((entry, index) => ( + + + + ))} + +
{index + 1}. {entry.title}
+
+ )} + ({ formik={formik} /> ; +}; export default NewPlaylistSummary; diff --git a/src/components/events/partials/wizards/NewPlaylistWizard.tsx b/src/components/events/partials/wizards/NewPlaylistWizard.tsx index 7076d34a1e..d641cad088 100644 --- a/src/components/events/partials/wizards/NewPlaylistWizard.tsx +++ b/src/components/events/partials/wizards/NewPlaylistWizard.tsx @@ -3,6 +3,7 @@ import { Formik } from "formik"; import NewMetadataCommonPage from "../ModalTabsAndPages/NewMetadataCommonPage"; import NewAccessPage from "../ModalTabsAndPages/NewAccessPage"; +import NewPlaylistEntriesPage from "../ModalTabsAndPages/NewPlaylistEntriesPage"; import NewPlaylistSummary from "./NewPlaylistSummary"; import WizardStepper, { WizardStep } from "../../../shared/wizard/WizardStepper"; import { initialFormValuesNewPlaylist } from "../../../../configs/modalConfig"; @@ -11,6 +12,7 @@ import { getInitialMetadataFieldValues } from "../../../../utils/resourceUtils"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { getNewPlaylistMetadataFields, postNewPlaylist } from "../../../../slices/playlistSlice"; import { TransformedAcl } from "../../../../slices/aclDetailsSlice"; +import { PlaylistEntry } from "../../../../slices/playlistDetailsSlice"; import { removeNotificationWizardForm } from "../../../../slices/notificationSlice"; import { getUserInformation } from "../../../../selectors/userInfoSelectors"; import { getAclDefaultActions, getAclDefaultTemplate } from "../../../../selectors/aclSelectors"; @@ -47,7 +49,7 @@ const NewPlaylistWizard = ({ const [page, setPage] = useState(0); const [pageCompleted, setPageCompleted] = useState<{ [key: number]: boolean }>({}); - type StepName = "metadata" | "access" | "summary"; + type StepName = "metadata" | "entries" | "access" | "summary"; type Step = WizardStep & { name: StepName, } @@ -57,6 +59,10 @@ const NewPlaylistWizard = ({ translation: "EVENTS.PLAYLISTS.NEW.METADATA.CAPTION", name: "metadata", }, + { + translation: "EVENTS.PLAYLISTS.DETAILS.TABS.ENTRIES", + name: "entries", + }, { translation: "EVENTS.PLAYLISTS.NEW.ACCESS.CAPTION", name: "access", @@ -90,6 +96,7 @@ const NewPlaylistWizard = ({ values: { metadata: { [key: string]: unknown }, policies: TransformedAcl[], + entries: PlaylistEntry[], }, ) => { // Extract metadata field values from the formik values @@ -133,6 +140,14 @@ const NewPlaylistWizard = ({ /> )} + {steps[page].name === "entries" && ( + + )} + {steps[page].name === "access" && ( state.playlistDetails.modal.pa export const getModalPlaylist = (state: RootState) => state.playlistDetails.modal.playlist; export const getPlaylistDetailsMetadata = (state: RootState) => state.playlistDetails.metadata; +export const getPlaylistDetailsEntries = (state: RootState) => state.playlistDetails.entries; +export const getPlaylistDetailsEntriesChanged = (state: RootState) => state.playlistDetails.entriesChanged; export const getPlaylistDetailsAcl = (state: RootState) => state.playlistDetails.acl; export const getPlaylistDetailsPolicyTemplateId = (state: RootState) => state.playlistDetails.policyTemplateId; diff --git a/src/slices/playlistDetailsSlice.ts b/src/slices/playlistDetailsSlice.ts index 8e43abc400..90c2b58bb6 100644 --- a/src/slices/playlistDetailsSlice.ts +++ b/src/slices/playlistDetailsSlice.ts @@ -15,6 +15,16 @@ import { PlaylistDetailsPage } from "../components/events/partials/modals/Playli /** * This file contains redux reducer for actions affecting the state of playlist details */ +export type PlaylistEntry = { + contentId: string, + type: string, + title: string, + date?: string, + series?: string, + presenters?: string[], +}; + + type PlaylistDetailsModal = { show: boolean, page: PlaylistDetailsPage, @@ -26,6 +36,8 @@ type PlaylistDetailsState = { errorMetadata: SerializedError | null, modal: PlaylistDetailsModal, metadata: MetadataCatalog, + entries: PlaylistEntry[], + entriesChanged: boolean, acl: TransformedAcl[], policyTemplateId: number, } @@ -91,6 +103,8 @@ const initialState: PlaylistDetailsState = { flavor: "", fields: [], }, + entries: [], + entriesChanged: false, acl: [], policyTemplateId: 0, }; @@ -121,7 +135,17 @@ const transformPlaylistAcl = (entries: Playlist["accessControlEntries"]): Transf return acl; }; +const mapEntries = (entries: Playlist["entries"]): PlaylistEntry[] => + entries.map(entry => ({ + contentId: entry.contentId, + type: entry.type, + title: entry.title ?? entry.contentId, + date: entry.start_date, + series: entry.series?.title, + presenters: entry.presenters, + })); +// Fetch playlist details (metadata + ACL + entries) from server in a single request export const fetchPlaylistDetails = createAppAsyncThunk("playlistDetails/fetchPlaylistDetails", async (id: string) => { const res = await axios.get(`/admin-ng/playlists/${id}`); const playlist = res.data; @@ -129,6 +153,7 @@ export const fetchPlaylistDetails = createAppAsyncThunk("playlistDetails/fetchPl return { metadata: playlistToMetadataCatalog(playlist), acl: transformPlaylistAcl(playlist.accessControlEntries), + entries: mapEntries(playlist.entries), }; }); @@ -159,6 +184,31 @@ export const updatePlaylistMetadata = createAppAsyncThunk("playlistDetails/updat }); +export const updatePlaylistEntries = createAppAsyncThunk("playlistDetails/updatePlaylistEntries", async (params: { + id: string, + entries: PlaylistEntry[], +}, { dispatch }) => { + const { id, entries } = params; + + const apiEntries = entries.map(e => ({ contentId: e.contentId, type: e.type })); + const data = new URLSearchParams(); + data.append("playlist", JSON.stringify({ entries: apiEntries })); + + await axios.put(`/admin-ng/playlists/${id}`, data, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + + dispatch(setPlaylistEntriesChanged(false)); + + dispatch(addNotification({ + type: "info", + key: "PLAYLIST_ENTRIES_UPDATED", + duration: 3, + context: NOTIFICATION_CONTEXT, + })); +}); + + export const updatePlaylistAccess = createAppAsyncThunk("playlistDetails/updatePlaylistAccess", async (params: { id: string, policies: { acl: Acl }, @@ -183,7 +233,7 @@ export const updatePlaylistAccess = createAppAsyncThunk("playlistDetails/updateP dispatch(addNotification({ type: "info", key: "SAVED_ACL_RULES", - duration: -1, + duration: 3, context: NOTIFICATION_CONTEXT, })); @@ -223,6 +273,12 @@ const playlistDetailsSlice = createSlice({ setPlaylistDetailsAcl(state, action: PayloadAction) { state.acl = action.payload; }, + setPlaylistDetailsEntries(state, action: PayloadAction) { + state.entries = action.payload; + }, + setPlaylistEntriesChanged(state, action: PayloadAction) { + state.entriesChanged = action.payload; + }, }, extraReducers: builder => { builder @@ -232,10 +288,13 @@ const playlistDetailsSlice = createSlice({ .addCase(fetchPlaylistDetails.fulfilled, (state, action: PayloadAction<{ metadata: MetadataCatalog, acl: TransformedAcl[], + entries: PlaylistEntry[], }>) => { state.statusMetadata = "succeeded"; state.metadata = action.payload.metadata; state.acl = action.payload.acl; + state.entries = action.payload.entries; + state.entriesChanged = false; }) .addCase(fetchPlaylistDetails.rejected, (state, action) => { state.statusMetadata = "failed"; @@ -250,6 +309,8 @@ export const { setModalPlaylist, setPlaylistDetailsMetadata, setPlaylistDetailsAcl, + setPlaylistDetailsEntries, + setPlaylistEntriesChanged, } = playlistDetailsSlice.actions; export default playlistDetailsSlice.reducer; diff --git a/src/slices/playlistSlice.ts b/src/slices/playlistSlice.ts index e8c68f4d62..2e759cc9e0 100644 --- a/src/slices/playlistSlice.ts +++ b/src/slices/playlistSlice.ts @@ -7,6 +7,7 @@ import { getURLParams, prepareAccessPolicyRulesForPost } from "../utils/resource import { playlistsTableConfig } from "../configs/tableConfigs/playlistsTableConfig"; import { addNotification } from "./notificationSlice"; import { TransformedAcl } from "./aclDetailsSlice"; +import { PlaylistEntry } from "./playlistDetailsSlice"; import { MetadataCatalog } from "./eventSlice"; import { AppThunk } from "../store"; @@ -55,6 +56,10 @@ export type Playlist = { id: number; contentId: string; type: string; + title?: string; + start_date?: string; + series?: { id: string, title: string }; + presenters?: string[]; }[]; title: string; description: string; @@ -120,6 +125,7 @@ export const postNewPlaylist = (params: { values: { policies: TransformedAcl[], metadata: { [key: string]: unknown }, + entries: PlaylistEntry[], }, metadataFields: { title: string, description: string, creator: string }, }): AppThunk => dispatch => { @@ -130,7 +136,7 @@ export const postNewPlaylist = (params: { title: metadataFields.title, description: metadataFields.description, creator: metadataFields.creator, - entries: [], + entries: (values.entries || []).map(e => ({ contentId: e.contentId, type: e.type })), }; // Build ACL diff --git a/src/styles/views/_views-config.scss b/src/styles/views/_views-config.scss index 6355050ee8..8a150b1048 100644 --- a/src/styles/views/_views-config.scss +++ b/src/styles/views/_views-config.scss @@ -31,6 +31,7 @@ @use "modals/event-series"; @use "modals/group"; @use "modals/hotkey-cheat-sheet"; +@use "modals/playlist"; @use "modals/publications"; @use "modals/modal-dialog"; @use "modals/new-event-series"; diff --git a/src/styles/views/modals/_playlist.scss b/src/styles/views/modals/_playlist.scss new file mode 100644 index 0000000000..d856c2d2f8 --- /dev/null +++ b/src/styles/views/modals/_playlist.scss @@ -0,0 +1,104 @@ +@use "../../base/variables"; + +/** + * Playlist entry editor styles. + * Shared between the playlist details modal and the new playlist wizard. + */ +.modal { + .playlist-entries-box { + margin-top: 15px; + + > table.main-tbl { + border: none; + padding: 6px 8px; + } + + .playlist-entry-count { + font-weight: normal; + color: variables.$color-gray; + } + } + + .playlist-entries-empty { + padding: 15px 20px; + color: variables.$color-gray; + font-size: 12px; + margin: 0; + line-height: 1; + } + + .playlist-entries-list { + max-height: 400px; + overflow-y: auto; + + &.drag-drop-items { + padding: 10px 15px; + } + + > div { + display: flex; + flex-direction: column; + gap: 8px; + } + + .playlist-entry-item { + margin: 0; + height: auto; + min-height: 35px; + padding: 6px 10px; + gap: 12px; + + &.drag-disabled { + cursor: default; + } + + .move-item { + position: static; + flex-shrink: 0; + } + + .entry-info { + flex: 1; + min-width: 0; + + .title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .entry-meta { + font-size: 11px; + font-weight: normal; + color: variables.$color-gray; + display: flex; + gap: 10px; + margin-top: 4px; + + .entry-meta-label { + font-weight: variables.$weight-bold; + } + + > span { + overflow-x: clip; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + .entry-link { + display: flex; + align-items: center; + color: variables.$color-gray; + margin-right: 8px; + flex-shrink: 0; + transition: color 0.15s; + + &:hover { + color: variables.$l-blue; + } + } + } + } +} diff --git a/src/utils/validate.ts b/src/utils/validate.ts index e0d53da092..6a8cccb85d 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -168,6 +168,7 @@ export const NewSeriesSchema = { export const NewPlaylistSchema: Record> = { // For metadata validation see MetadataSchema "metadata": Yup.object().shape({}), + "entries": Yup.object().shape({}), "access": Yup.object().shape({}), "summary": Yup.object().shape({}), }; From 06f6ad864537f10999074e48a3b5d48c5cce7487 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Tue, 17 Mar 2026 12:31:47 +0100 Subject: [PATCH 6/7] Add note for unknown playlist entries Events don't remove themselves automatically from playlists when they are deleted. Previously I would just show their UUID, but that isn't very helpful. This will now show an "unknown entry" note, which is slightly better I suppose. --- .../partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx | 4 ++-- .../org/opencastproject/adminui/languages/lang-en_US.json | 1 + src/slices/playlistDetailsSlice.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx b/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx index 92d1cb2e33..32f4b90002 100644 --- a/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx +++ b/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx @@ -251,8 +251,8 @@ const PlaylistEntriesEditor = ({ >
-
- {entry.title} +
+ {entry.title || `${t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.UNKNOWN")} [${entry.contentId}]`}
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 16a60751a4..f2ca294350 100644 --- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json +++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json @@ -1342,6 +1342,7 @@ "EMPTY": "This playlist has no entries yet.", "NO_AVAILABLE": "There are no events available to add.", "REMOVE": "Remove entry", + "UNKNOWN": "Unknown entry", "OPEN_PLAYER": "Open in player", "DATE_LABEL": "Created: ", "SERIES_LABEL": "Series: ", diff --git a/src/slices/playlistDetailsSlice.ts b/src/slices/playlistDetailsSlice.ts index 90c2b58bb6..a5fcf17a90 100644 --- a/src/slices/playlistDetailsSlice.ts +++ b/src/slices/playlistDetailsSlice.ts @@ -18,7 +18,7 @@ import { PlaylistDetailsPage } from "../components/events/partials/modals/Playli export type PlaylistEntry = { contentId: string, type: string, - title: string, + title?: string, date?: string, series?: string, presenters?: string[], @@ -139,7 +139,7 @@ const mapEntries = (entries: Playlist["entries"]): PlaylistEntry[] => entries.map(entry => ({ contentId: entry.contentId, type: entry.type, - title: entry.title ?? entry.contentId, + title: entry.title, date: entry.start_date, series: entry.series?.title, presenters: entry.presenters, From 47929818ef6c5fc10e50173f22dc4b428e167559 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Tue, 17 Mar 2026 17:49:13 +0100 Subject: [PATCH 7/7] Restore eslint config --- eslint.config.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index ce0df9d0fa..3df3c47f4d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,12 +11,12 @@ export default [ { rules: { // TODO: We want to turn these on eventually - // "indent": "off", - // "max-len": "off", - // "no-tabs": "off", - // "@typescript-eslint/no-explicit-any": "off", - // "@typescript-eslint/no-floating-promises": "off", - // "@typescript-eslint/no-misused-promises": "off", + "indent": "off", + "max-len": "off", + "no-tabs": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-misused-promises": "off", }, }, ];