diff --git a/.gitignore b/.gitignore index f9cc73b2f7..86dd1ebbc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules /build .idea/ +.editorconfig diff --git a/src/App.tsx b/src/App.tsx index 81c18f7daa..3eef57255d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { useAppDispatch } from "./store"; import { fetchOcVersion, fetchUserInfo } from "./slices/userInfoSlice"; import { subscribeToAuthEvents } from "./utils/broadcastSync"; import { useTableFilterStateValidation } from "./hooks/useTableFilterStateValidation"; +import Playlists from "./components/events/Playlists"; function App() { const dispatch = useAppDispatch(); @@ -47,6 +48,8 @@ function App() { } /> + } /> + } /> } /> diff --git a/src/components/events/Playlists.tsx b/src/components/events/Playlists.tsx new file mode 100644 index 0000000000..c79c023d59 --- /dev/null +++ b/src/components/events/Playlists.tsx @@ -0,0 +1,43 @@ +;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"; +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 dispatch = useAppDispatch(); + + const onNewPlaylistModal = async () => { + await dispatch(fetchAclDefaults()); + }; + + return <> + + + + ; +}; + +export default Playlists; diff --git a/src/components/events/partials/EventsNavigation.tsx b/src/components/events/partials/EventsNavigation.tsx index 5c21a230ec..f61376ba08 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_EVENTS_VIEW", // TODO + text: "EVENTS.PLAYLISTS.TABLE.CAPTION", + }, ]; 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/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/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/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..dab2f09439 --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/PlaylistEntriesEditor.tsx @@ -0,0 +1,296 @@ +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 { Tooltip } from "../../../shared/Tooltip"; +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 || `${t("EVENTS.PLAYLISTS.DETAILS.ENTRIES.UNKNOWN")} [${entry.contentId}]`} +
+ +
+ {showEngageLinks && engageUrl && ( + + e.stopPropagation()} + > + + + + )} + 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/PlaylistActionsCell.tsx b/src/components/events/partials/PlaylistActionsCell.tsx new file mode 100644 index 0000000000..91b15655e8 --- /dev/null +++ b/src/components/events/partials/PlaylistActionsCell.tsx @@ -0,0 +1,48 @@ +import { LuFileText } from "react-icons/lu"; + +import { fetchPlaylistDetails, openModal } from "../../../slices/playlistDetailsSlice"; +import { useAppDispatch } from "../../../store"; +import { deletePlaylist, Playlist } from "../../../slices/playlistSlice"; +import ButtonLikeAnchor from "../../shared/ButtonLikeAnchor"; +import { PlaylistDetailsPage } from "./modals/PlaylistDetails"; +import { ActionCellDelete } from "../../shared/ActionCellDelete"; + + +/** + * 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 more-series"} + editAccessRole={"ROLE_UI_PLAYLISTS_DETAILS_VIEW"} + > + + + + {/* delete playlist */} + dispatch(deletePlaylist(id))} + /> + ; +}; + +export default PlaylistActionsCell; 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/PlaylistTitleCell.tsx b/src/components/events/partials/PlaylistTitleCell.tsx new file mode 100644 index 0000000000..3b7eb97910 --- /dev/null +++ b/src/components/events/partials/PlaylistTitleCell.tsx @@ -0,0 +1,8 @@ +import { Playlist } from "../../../slices/playlistSlice"; + +/** + * This component renders the title cells of playlists in the table view + */ +const PlaylistTitleCell = ({ row }: { row: Playlist }) => {row.title}; + +export default PlaylistTitleCell; 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/events/partials/modals/PlaylistDetails.tsx b/src/components/events/partials/modals/PlaylistDetails.tsx new file mode 100644 index 0000000000..c4da2154b9 --- /dev/null +++ b/src/components/events/partials/modals/PlaylistDetails.tsx @@ -0,0 +1,121 @@ +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 { + 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, +} + +/** + * 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 entriesChanged = useAppSelector(state => getPlaylistDetailsEntriesChanged(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.ENTRIES", + accessRole: "ROLE_UI_PLAYLISTS_DETAILS_METADATA_VIEW", + name: "entries", + }, + { + tabNameTranslation: "EVENTS.PLAYLISTS.DETAILS.TABS.PERMISSIONS", + accessRole: "ROLE_UI_PLAYLISTS_DETAILS_ACL_VIEW", + name: "permissions", + }, + ]; + + const openTab = (tabNr: number) => { + let isUnsavedChanges = policyChanged || entriesChanged; + if (formikRef.current?.dirty) { + isUnsavedChanges = true; + } + + if (!isUnsavedChanges || confirmUnsaved(t)) { + setPage(tabNr); + } + }; + + return <> + {/* Tab navigation */} + + + {/* Tab content */} +
+ {page === 0 && ( + + )} + {page === 1 && } + {page === 2 && } +
+ ; +}; + +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..c02004c3fa --- /dev/null +++ b/src/components/events/partials/modals/PlaylistDetailsModal.tsx @@ -0,0 +1,77 @@ +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, + setPlaylistEntriesChanged, + setShowModal, +} from "../../../../slices/playlistDetailsSlice"; +import { + getModalPlaylist, + getPlaylistDetailsEntriesChanged, + 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 entriesChanged = useAppSelector(state => getPlaylistDetailsEntriesChanged(state)); + + const hideModal = () => { + dispatch(setModalPlaylist(null)); + dispatch(setShowModal(false)); + }; + + const close = () => { + let isUnsavedChanges = policyChanged || entriesChanged; + if (formikRef.current?.dirty) { + isUnsavedChanges = true; + } + + if (!isUnsavedChanges || confirmUnsaved(t)) { + setPolicyChanged(false); + dispatch(setPlaylistEntriesChanged(false)); + dispatch(removeNotificationWizardForm()); + hideModal(); + return true; + } + return false; + }; + + return <> + {displayPlaylistDetailsModal && + + setPolicyChanged(value)} + formikRef={formikRef} + /> + + } + ; +}; + +export default PlaylistDetailsModal; diff --git a/src/components/events/partials/wizards/NewPlaylistSummary.tsx b/src/components/events/partials/wizards/NewPlaylistSummary.tsx new file mode 100644 index 0000000000..f158f40bce --- /dev/null +++ b/src/components/events/partials/wizards/NewPlaylistSummary.tsx @@ -0,0 +1,75 @@ +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"; + + +/** + * Summary page for new playlists in new playlist wizard. + */ +interface RequiredFormProps { + policies: TransformedAcl[], + metadata: { [key: string]: unknown }, + entries: PlaylistEntry[], +} + +const NewPlaylistSummary = ({ + formik, + previousPage, + metadataFields, +}: { + formik: FormikProps, + previousPage: (values: T, twoPagesBack?: boolean) => void, + metadataFields: MetadataCatalog, +}) => { + const { t } = useTranslation(); + + return <> + + + + {/* Summary entries */} + {formik.values.entries.length > 0 && ( +
+
+ {t("EVENTS.PLAYLISTS.DETAILS.TABS.ENTRIES")} +
+ + + {formik.values.entries.map((entry, index) => ( + + + + ))} + +
{index + 1}. {entry.title}
+
+ )} + + +
+ + + ; +}; + + +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..628a9b52b6 --- /dev/null +++ b/src/components/events/partials/wizards/NewPlaylistWizard.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect } from "react"; +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"; +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 { PlaylistEntry } from "../../../../slices/playlistDetailsSlice"; +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. + * Currently supports metadata and access policy steps. + */ +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" | "entries" | "access" | "summary"; + type Step = WizardStep & { + name: StepName, + } + + const steps: Step[] = [ + { + translation: "EVENTS.PLAYLISTS.NEW.METADATA.CAPTION", + name: "metadata", + }, + { + translation: "EVENTS.PLAYLISTS.DETAILS.TABS.ENTRIES", + name: "entries", + }, + { + 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[], + entries: PlaylistEntry[], + }, + ) => { + // 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 === "entries" && ( + + )} + + {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/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/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/components/shared/SaveEditFooter.tsx b/src/components/shared/SaveEditFooter.tsx index e743a1f3ec..f25e68dcbe 100644 --- a/src/components/shared/SaveEditFooter.tsx +++ b/src/components/shared/SaveEditFooter.tsx @@ -1,8 +1,10 @@ import { useTranslation } from "react-i18next"; -import { Tooltip } from "./Tooltip"; import { ParseKeys } from "i18next"; + +import { Tooltip } from "./Tooltip"; import BaseButton from "./BaseButton"; + type SaveEditFooterProps = { active: boolean; reset: () => void; @@ -17,44 +19,41 @@ 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
+ return
+ {t(saveButtonText)} + {additionalButton && ( + {t(saveButtonText)} - {additionalButton && ( - - {t(additionalButton.label)} - - )} - {active && isValid && ( - {t("CANCEL")} - )} -
; + onClick={additionalButton.onClick} + disabled={disabled} + aria-disabled={disabled} + className={`save green ${disabled ? "disabled" : ""}`} + >{t(additionalButton.label)} + + )} + {t("CANCEL")} +
; }; diff --git a/src/configs/modalConfig.ts b/src/configs/modalConfig.ts index b1c9cf6ed9..bbe35452c6 100644 --- a/src/configs/modalConfig.ts +++ b/src/configs/modalConfig.ts @@ -7,6 +7,7 @@ import { EditedEvents, Event, UploadAssetsTrack } from "../slices/eventSlice"; import { Role } from "../slices/aclSlice"; import { ParseKeys } from "i18next"; import { UserRole } from "../slices/userSlice"; +import { PlaylistEntry } from "../slices/playlistDetailsSlice"; // Context for notifications shown in modals export const NOTIFICATION_CONTEXT = "modal-form"; @@ -122,6 +123,25 @@ export const initialFormValuesNewSeries: { metadata: {}, }; + +export const initialFormValuesNewPlaylist: { + policies: TransformedAcl[], + aclTemplate?: string, + metadata: { [key: string]: unknown }, + entries: PlaylistEntry[], +} = { + policies: [ + { + role: "ROLE_USER_ADMIN", + read: true, + write: true, + actions: [], + }, + ], + metadata: {}, + entries: [], +}; + // 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/configs/tableConfigs/playlistsTableConfig.ts b/src/configs/tableConfigs/playlistsTableConfig.ts new file mode 100644 index 0000000000..51330bc1d2 --- /dev/null +++ b/src/configs/tableConfigs/playlistsTableConfig.ts @@ -0,0 +1,45 @@ +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: [ + { + template: "PlaylistTitleCell", + 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, + }, + { + template: "PlaylistActionsCell", + name: "actions", + label: "EVENTS.PLAYLISTS.TABLE.ACTION", + }, + ], + 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..ecf8d7701b --- /dev/null +++ b/src/configs/tableConfigs/playlistsTableMap.ts @@ -0,0 +1,14 @@ +import PlaylistTitleCell from "../../components/events/partials/PlaylistTitleCell"; +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. + */ +export const playlistsTemplateMap = { + PlaylistTitleCell: PlaylistTitleCell, + 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 91ba02dac7..ef55e43e12 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,11 @@ "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", + "PLAYLIST_ENTRIES_UPDATED": "The playlist entries have been updated", "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", @@ -623,12 +629,14 @@ "NAVIGATION": { "EVENTS": "Events", "SERIES": "Series", + "PLAYLISTS": "Playlists", "OVERVIEW": "Overview", "LABEL": "Switch between events and series." }, "UPLOAD": "Upload", "ADD_SERIES": "Add series", "ADD_EVENT": "Add event", + "ADD_PLAYLIST": "Add playlist", "TABLE": { "CAPTION": "Events", "TITLE": "Title", @@ -1284,6 +1292,74 @@ "LINK": "Link" } } + }, + "PLAYLISTS": { + "NEW": { + "CAPTION": "Add playlist", + "METADATA": { + "CAPTION": "Metadata" + }, + "ACCESS": { + "CAPTION": "Access policy" + }, + "SUMMARY": { + "CAPTION": "Summary" + } + }, + "TABLE": { + "CAPTION": "Playlists", + "TITLE": "Title", + "DESCRIPTION": "Description", + "CREATOR": "Creator", + "UPDATED": "Updated", + "ACTION": "Action", + "TOOLTIP": { + "DETAILS": "Open playlist details" + } + }, + "DETAILS": { + "HEADER": "Playlist details - {{name}}", + "TABS": { + "METADATA": "Metadata", + "ENTRIES": "Entries", + "PERMISSIONS": "Access policy" + }, + "METADATA": { + "TITLE": "Title", + "DESCRIPTION": "Description", + "CREATOR": "Creator", + "UPDATED": "Updated", + "ENTRIES": "Entries" + }, + "ENTRIES": { + "AVAILABLE": "Add Events", + "SEARCH_PLACEHOLDER": "Search events by title...", + "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: ", + "PRESENTERS_LABEL": "Presenter(s): " + }, + "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" + } + } } }, "RECORDINGS": { @@ -1981,6 +2057,14 @@ "LABEL": "Created" } }, + "PLAYLISTS": { + "CREATOR": { + "LABEL": "Creator" + }, + "UPDATED": { + "LABEL": "Updated" + } + }, "USERS": { "PROVIDER": { "LABEL": "Provider" diff --git a/src/selectors/playlistDetailsSelectors.ts b/src/selectors/playlistDetailsSelectors.ts new file mode 100644 index 0000000000..e6534cc02e --- /dev/null +++ b/src/selectors/playlistDetailsSelectors.ts @@ -0,0 +1,15 @@ +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 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/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/playlistDetailsSlice.ts b/src/slices/playlistDetailsSlice.ts new file mode 100644 index 0000000000..a5fcf17a90 --- /dev/null +++ b/src/slices/playlistDetailsSlice.ts @@ -0,0 +1,316 @@ +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 + */ +export type PlaylistEntry = { + contentId: string, + type: string, + title?: string, + date?: string, + series?: string, + presenters?: string[], +}; + + +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, + entries: PlaylistEntry[], + entriesChanged: boolean, + 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: [], + }, + entries: [], + entriesChanged: false, + 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; +}; + +const mapEntries = (entries: Playlist["entries"]): PlaylistEntry[] => + entries.map(entry => ({ + contentId: entry.contentId, + type: entry.type, + title: entry.title, + 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; + + return { + metadata: playlistToMetadataCatalog(playlist), + acl: transformPlaylistAcl(playlist.accessControlEntries), + entries: mapEntries(playlist.entries), + }; +}); + + +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 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 }, + 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: 3, + 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; + }, + setPlaylistDetailsEntries(state, action: PayloadAction) { + state.entries = action.payload; + }, + setPlaylistEntriesChanged(state, action: PayloadAction) { + state.entriesChanged = action.payload; + }, + }, + extraReducers: builder => { + builder + .addCase(fetchPlaylistDetails.pending, state => { + state.statusMetadata = "loading"; + }) + .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"; + state.errorMetadata = action.error; + }); + }, +}); + +export const { + setShowModal, + setModalPage, + setModalPlaylist, + setPlaylistDetailsMetadata, + setPlaylistDetailsAcl, + setPlaylistDetailsEntries, + setPlaylistEntriesChanged, +} = playlistDetailsSlice.actions; + +export default playlistDetailsSlice.reducer; diff --git a/src/slices/playlistSlice.ts b/src/slices/playlistSlice.ts new file mode 100644 index 0000000000..391be6d726 --- /dev/null +++ b/src/slices/playlistSlice.ts @@ -0,0 +1,214 @@ +import { createSlice, PayloadAction, SerializedError } from "@reduxjs/toolkit"; +import axios from "axios"; + +import { TableConfig } from "../configs/tableConfigs/aclsTableConfig"; +import { createAppAsyncThunk } from "../createAsyncThunkWithTypes"; +import { getURLParams, prepareAccessPolicyRulesForPost } from "../utils/resourceUtils"; +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"; + +/** + * 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 = { + id: string; + organization?: string; + entries: { + id: number; + contentId: string; + type: string; + title?: string; + start_date?: string; + series?: { id: string, title: string }; + presenters?: 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 = { + 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; +}); + + +export const postNewPlaylist = (params: { + values: { + policies: TransformedAcl[], + metadata: { [key: string]: unknown }, + entries: PlaylistEntry[], + }, + 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: (values.entries || []).map(e => ({ contentId: e.contentId, type: e.type })), + }; + + // 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, + 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.results.length; + state.count = playlist.results.length; + }) + .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 3dbe3c2c5c..7d27132a27 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" @@ -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 76a3f65d9f..7de2c8386c 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"; @@ -23,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"; @@ -38,6 +40,7 @@ import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2"; const tableFilterProfilesPersistConfig = { key: "tableFilterProfiles", storage, whitelist: ["profiles"] }; const eventsPersistConfig = { key: "events", storage, whitelist: ["columns"] }; const seriesPersistConfig = { key: "series", storage, whitelist: ["columns"] }; +const 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"] }; @@ -50,39 +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), - 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); 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/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()); diff --git a/src/utils/validate.ts b/src/utils/validate.ts index 9ef13471b9..6a8cccb85d 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -164,6 +164,15 @@ 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({}), + "entries": 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({