diff --git a/apps/client/src/common/context/EntryActionsContext.tsx b/apps/client/src/common/context/EntryActionsContext.tsx index 983e160de9..b5159a42e4 100644 --- a/apps/client/src/common/context/EntryActionsContext.tsx +++ b/apps/client/src/common/context/EntryActionsContext.tsx @@ -1,15 +1,15 @@ import { PropsWithChildren, createContext, useContext } from 'react'; -import { useEntryActions } from '../hooks/useEntryAction'; +import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction'; +import { useRundownSelectionContext } from './RundownSelectionContext'; type EntryActionsContextValue = ReturnType; const EntryActionsContext = createContext(null); -interface EntryActionsProviderProps extends PropsWithChildren { - actions: EntryActionsContextValue; -} +export function EntryActionsProvider({ children }: PropsWithChildren) { + const { effectiveRundownId } = useRundownSelectionContext(); + const actions = useScopedEntryActions(effectiveRundownId); -export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) { return {children}; } diff --git a/apps/client/src/common/context/RundownSelectionContext.tsx b/apps/client/src/common/context/RundownSelectionContext.tsx new file mode 100644 index 0000000000..403160327e --- /dev/null +++ b/apps/client/src/common/context/RundownSelectionContext.tsx @@ -0,0 +1,72 @@ +import { Maybe, ProjectRundown } from 'ontime-types'; +import { + PropsWithChildren, + createContext, + startTransition, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { useProjectRundowns } from '../hooks-query/useProjectRundowns'; + +export type RundownScopeValue = { + loadedRundownId: string; + selectedRundownId: Maybe; + isLoadedRundown: boolean; + effectiveRundownId: string; + selectRundownId: (val: Maybe) => void; + rundowns: ProjectRundown[]; +}; + +const RundownScopeContext = createContext(null); + +export function RundownSelectionContextProvider({ children }: PropsWithChildren) { + 'use memo'; + const { data } = useProjectRundowns(); + const { loaded, rundowns } = data; + const [selectedRundownId, setSelectedRundownId] = useState>(null); + + const selectRundownId = useCallback( + (rundownId: Maybe) => { + startTransition(() => { + if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId); + else setSelectedRundownId(null); + }); + }, + [rundowns], + ); + + const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded; + const isLoadedRundown = effectiveRundownId === loaded; + + useEffect(() => { + if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null); + }, [rundowns, effectiveRundownId]); + + const value = useMemo( + (): RundownScopeValue => ({ + loadedRundownId: loaded, + isLoadedRundown, + selectedRundownId, + effectiveRundownId, + selectRundownId, + rundowns, + }), + [loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns], + ); + + return {children}; +} + +export function useRundownSelectionContext() { + const context = useContext(RundownScopeContext); + + if (!context) { + throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider'); + } + + return context; +} diff --git a/apps/client/src/common/hooks-query/useContextRundown.ts b/apps/client/src/common/hooks-query/useContextRundown.ts new file mode 100644 index 0000000000..142eb0b307 --- /dev/null +++ b/apps/client/src/common/hooks-query/useContextRundown.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; + +import { useRundownSelectionContext } from '../context/RundownSelectionContext'; +import { useSelectedEventId } from '../hooks/useSocket'; +import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; +import { useRundown } from './useRundown'; + +export function useContextRundownEditModal() { + 'use memo'; + const { effectiveRundownId } = useRundownSelectionContext(); + const { data: rundown } = useRundown(effectiveRundownId); + return { rundown }; +} + +export function useContextRundownCueRenumberModal() { + 'use memo'; + const { effectiveRundownId } = useRundownSelectionContext(); + const { data } = useRundown(effectiveRundownId); + const { flatOrder } = data; + return { flatOrder }; +} + +export function useContextRundownList() { + 'use memo'; + const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext(); + const loadedEventId = useSelectedEventId(); + const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null; + const { data: rundown, status } = useRundown(effectiveRundownId); + const rundownMetadata = useMemo( + () => getRundownMetadata(rundown, effectiveSelectedEventId), + [effectiveSelectedEventId, rundown], + ); + + return useMemo( + () => ({ + rundown, + rundownMetadata, + status, + isLoadedRundown, + }), + [rundown, rundownMetadata, status, isLoadedRundown], + ); +} + +export function useContextRundownTable() { + 'use memo'; + const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext(); + const loadedEventId = useSelectedEventId(); + const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null; + const { data: rundown, status } = useRundown(effectiveRundownId); + const flatRundown = useMemo( + () => getFlatRundownMetadata(rundown, effectiveSelectedEventId), + [effectiveSelectedEventId, rundown], + ); + + return useMemo( + () => ({ + flatRundown, + status, + loadedEventId, + }), + [flatRundown, status, loadedEventId], + ); +} diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 4912f688b4..d1a6d70241 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,10 +1,10 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; -import { useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types'; +import { useMemo } from 'react'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; -import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; -import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; +import { getRundownQueryKey } from '../api/constants'; +import { fetchRundown } from '../api/rundown'; import { useSelectedEventId } from '../hooks/useSocket'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { useProjectRundowns } from './useProjectRundowns'; @@ -20,52 +20,49 @@ const cachedRundownPlaceholder: Rundown = { }; /** - * Normalised rundown data for the currently loaded rundown. - * - * Bootstraps via the `/current` alias so the first paint is a single round-trip, - * independent of the project rundown list. Once the loaded id is known, the - * query key swaps to the id-keyed cache that is shared with `useRundownById`. + * Provides access to a specific rundown by ID. + * When rundownId is not provided the loaded rundown is provided */ -export default function useRundown() { - const queryClient = useQueryClient(); +export function useRundown(rundownId: Maybe) { + 'use memo'; + const { data: { loaded: loadedRundownId }, } = useProjectRundowns(); + const effectiveRundownId = rundownId ? rundownId : loadedRundownId; + const isLoadedRundown = rundownId === loadedRundownId; + const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, - queryFn: ({ signal }) => fetchCurrentRundown({ signal }), + queryKey: getRundownQueryKey(effectiveRundownId), + queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }), + placeholderData: (previousData, _previousQuery) => previousData, refetchInterval: queryRefetchIntervalSlow, }); - // Seed the id-keyed cache when fetching via the bootstrap alias - useEffect(() => { - if (!data || loadedRundownId) return; - queryClient.setQueryData(getRundownQueryKey(data.id), data); - }, [data, loadedRundownId, queryClient]); - - // Once we have the ID, drop the temporary current cache - useEffect(() => { - if (!loadedRundownId) return; - queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); - }, [loadedRundownId, queryClient]); - - return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; + return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown }; } -export function useRundownWithMetadata() { - const { data, status } = useRundown(); +/** + * @deprecated + */ +export function useRundownWithMetadata(rundownId: Maybe) { + 'use memo'; + + const { data, status, isLoadedRundown } = useRundown(rundownId); const selectedEventId = useSelectedEventId(); - const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null; + const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId); return { data, status, rundownMetadata }; } /** * Provides access to a flat rundown * built from the order and rundown fields + * @deprecated */ -export function useFlatRundown() { - const { data, status } = useRundown(); +export function useFlatRundown(rundownId: Maybe) { + const { data, status } = useRundown(rundownId); const flatRundown = useMemo(() => { if (data.revision === -1) { @@ -77,11 +74,13 @@ export function useFlatRundown() { return { data: flatRundown, rundownId: data.id, status }; } -export function useFlatRundownWithMetadata() { - const { data, status } = useRundown(); - const selectedEventId = useSelectedEventId(); +export function useFlatRundownWithMetadata(rundownId: Maybe) { + 'use memo'; - const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const { data, status, isLoadedRundown } = useRundown(rundownId); + const selectedEventId = useSelectedEventId(); + const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null; + const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId); return { data: rundownWithMetadata, status }; } @@ -91,9 +90,10 @@ export function useFlatRundownWithMetadata() { * Callers MUST memoize the callback with useCallback to prevent * re-filtering on every render. * + * @deprecated */ -export function usePartialRundown(cb: (event: ExtendedEntry) => boolean) { - const { data, status } = useFlatRundownWithMetadata(); +export function usePartialRundown(rundownId: Maybe, cb: (event: ExtendedEntry) => boolean) { + const { data, status } = useFlatRundownWithMetadata(rundownId); const filteredData = useMemo(() => { return data.filter(cb); }, [data, cb]); @@ -103,37 +103,24 @@ export function usePartialRundown(cb: (event: ExtendedEntry) => boo /** * Hook to get a specific entry by ID from the rundown + * @deprecated */ -export function useEntry(entryId: EntryId | null): OntimeEntry | null { - const { data: rundown } = useRundown(); +export function useEntry(rundownId: Maybe, entryId: EntryId | null): OntimeEntry | null { + const { data: rundown } = useRundown(rundownId); if (entryId === null) return null; return rundown.entries[entryId] ?? null; } -export function useRundownAuxData() { - const { data, status } = useRundown(); +/** + * + * @deprecated + */ +export function useRundownAuxData(rundownId: Maybe) { + const { data, status } = useRundown(rundownId); const filteredData = useMemo(() => { const { title, id } = data; return { title, id }; }, [data]); return { data: filteredData, status }; } - -/** - * Provides access to a specific rundown by ID. - * When rundownId is null/undefined the query is disabled and returns the placeholder. - */ -export function useRundownById(rundownId: string | null | undefined) { - const enabled = Boolean(rundownId); - - const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: getRundownQueryKey(rundownId ?? ''), - queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }), - enabled, - placeholderData: (previousData, _previousQuery) => previousData, - refetchInterval: queryRefetchIntervalSlow, - }); - - return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; -} diff --git a/apps/client/src/common/hooks-query/useScopedRundown.ts b/apps/client/src/common/hooks-query/useScopedRundown.ts deleted file mode 100644 index a14707d7ff..0000000000 --- a/apps/client/src/common/hooks-query/useScopedRundown.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { EntryId, Rundown } from 'ontime-types'; -import { useMemo } from 'react'; - -import { useSelectedEventId } from '../hooks/useSocket'; -import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata'; -import { useProjectRundowns } from './useProjectRundowns'; -import { useRundownById } from './useRundown'; - -export type RundownSource = { - rundownId: string | null; - rundown: Rundown; - flatRundown: ExtendedEntry[]; - status: string; - selectedEventId: EntryId | null; -}; - -/** - * Explicitly scoped rundown data for views that may operate on a non-loaded rundown. - */ -export function useScopedRundown(rundownId: string | null): RundownSource { - const { data: projectRundowns } = useProjectRundowns(); - return useRundownSource(rundownId, projectRundowns.loaded || null); -} - -/** - * Loaded-rundown source for views that must follow the active runtime rundown. - */ -export function useLoadedRundownSource(): RundownSource { - const { data: projectRundowns } = useProjectRundowns(); - const loadedRundownId = projectRundowns.loaded || null; - return useRundownSource(loadedRundownId, loadedRundownId); -} - -function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource { - const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId; - const runtimeSelectedEventId = useSelectedEventId(); - const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null; - const { data: rundown, status } = useRundownById(rundownId); - const flatRundown = useMemo( - () => getFlatRundownMetadata(rundown, effectiveSelectedEventId), - [effectiveSelectedEventId, rundown], - ); - - return useMemo( - () => ({ - rundownId, - rundown, - flatRundown, - status, - selectedEventId: effectiveSelectedEventId, - }), - [effectiveSelectedEventId, flatRundown, rundown, rundownId, status], - ); -} diff --git a/apps/client/src/common/utils/rundownMetadata.ts b/apps/client/src/common/utils/rundownMetadata.ts index 5e410bae04..a89c187aa3 100644 --- a/apps/client/src/common/utils/rundownMetadata.ts +++ b/apps/client/src/common/utils/rundownMetadata.ts @@ -27,6 +27,7 @@ export type RundownMetadata = { groupColour: string | undefined; groupEntries: number | undefined; isFirstAfterGroup: boolean; + isParentToLoaded: boolean; // if the group contains the loaded event }; export type ExtendedEntry = T & RundownMetadata; @@ -94,6 +95,7 @@ export function initRundownMetadata(selectedEventId: MaybeString) { groupColour: undefined, groupEntries: undefined, isFirstAfterGroup: false, + isParentToLoaded: false, }; function process(entry: OntimeEntry): Readonly { @@ -117,6 +119,7 @@ function processEntry( // initialise data to be overridden below processedData.isNextDay = false; processedData.isLoaded = false; + processedData.isParentToLoaded = false; processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration processedData.thisId = entry.id; // we reassign thisId @@ -132,6 +135,7 @@ function processEntry( processedData.groupId = entry.id; processedData.groupColour = entry.colour; processedData.groupEntries = entry.entries.length; + processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false; } else { // for delays and groups, we insert the group metadata if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index a9366cc364..72e451ffc5 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -14,7 +14,6 @@ import { APP_SETTINGS, CLIENT_LIST, CSS_OVERRIDE, - CURRENT_RUNDOWN_QUERY_KEY, CUSTOM_FIELDS, PROJECT_DATA, REPORT, @@ -238,7 +237,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s if (!rundownId) { // we omit rundownId to signify invalidate all rundowns ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); - ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); return; } @@ -252,12 +250,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s } ontimeQueryClient.invalidateQueries({ queryKey, exact: true }); - - // keep current alias in sync with the ID-based cache - const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded; - if (!loadedRundownId || loadedRundownId === rundownId) { - ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); - } } export function sendSocket( diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx index 7dafb2a654..124cca8d56 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx @@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report'; import { createBlob, downloadBlob } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import useReport from '../../../../common/hooks-query/useReport'; -import useRundown from '../../../../common/hooks-query/useRundown'; +import { useRundown } from '../../../../common/hooks-query/useRundown'; import { cx } from '../../../../common/utils/styleUtils'; import { formatTime } from '../../../../common/utils/time'; import * as Panel from '../../panel-utils/PanelUtils'; @@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss'; export default function ReportSettings() { const { data: reportData } = useReport(); - const { data } = useRundown(); + const { data } = useRundown(null); const clearReport = async () => await deleteAllReport(); const downloadCSV = (combinedReport: CombinedReport[]) => { diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx index 9a05388a55..1c78c3716f 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx @@ -21,7 +21,7 @@ import Button from '../../../../../common/components/buttons/Button'; import Info from '../../../../../common/components/info/Info'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import Modal from '../../../../../common/components/modal/Modal'; -import useRundown from '../../../../../common/hooks-query/useRundown'; +import { useRundown } from '../../../../../common/hooks-query/useRundown'; import { validateExcelImport } from '../../../../../common/utils/uploadUtils'; import * as Panel from '../../../panel-utils/PanelUtils'; import GSheetSetup from './GSheetSetup'; @@ -54,7 +54,7 @@ export default function SourcesPanel() { const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none'); const [activeSource, setActiveSource] = useState(null); - const { data: currentRundown } = useRundown(); + const { data: currentRundown } = useRundown(null); const { importRundown } = useSpreadsheetImport(); const fileInputRef = useRef(null); diff --git a/apps/client/src/features/operator/useOperatorData.ts b/apps/client/src/features/operator/useOperatorData.ts index 8eeeae6b26..0063602505 100644 --- a/apps/client/src/features/operator/useOperatorData.ts +++ b/apps/client/src/features/operator/useOperatorData.ts @@ -14,7 +14,7 @@ export interface OperatorData { } export function useOperatorData(): ViewData { - const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); + const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null); const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: settings, status: settingsStatus } = useSettings(); diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index 0fcebf4158..6ba4f2caa8 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -6,8 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary' import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; -import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown'; -import { useEntryActions } from '../../common/hooks/useEntryAction'; +import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { handleLinks } from '../../common/utils/linkUtils'; import { cx } from '../../common/utils/styleUtils'; @@ -39,28 +38,29 @@ function RundownExport() { defaultValue: RundownViewMode.List, }); const isSmallDevice = useIsSmallDevice(); - const entryActions = useEntryActions(); if (isSmallDevice && isExtracted) { return ( - - -
- - -
- - - - + + + +
+ + +
+ + + + +
-
- - + + + ); } @@ -70,30 +70,32 @@ function RundownExport() { viewMode === RundownViewMode.Table; return ( - - -
- - {isExtracted && } -
- - - {!isExtracted && handleLinks('rundown', event)} />} - - - - - {!hideSideBar && ( -
+ + + +
+ + {isExtracted && } +
+ - + {!isExtracted && handleLinks('rundown', event)} />} + + -
- )} + + {!hideSideBar && ( +
+ + + +
+ )} +
-
- - + + + ); } @@ -105,8 +107,6 @@ interface RundownRootProps { } function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { - const source = useLoadedRundownSource(); - return (
{isSmallDevice ? ( @@ -115,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund )} {viewMode === RundownViewMode.List ? : } - {viewMode === RundownViewMode.Table && } + {viewMode === RundownViewMode.Table && }
); diff --git a/apps/client/src/features/rundown/RundownList.tsx b/apps/client/src/features/rundown/RundownList.tsx index 5718a8ea1c..cfc9abac00 100644 --- a/apps/client/src/features/rundown/RundownList.tsx +++ b/apps/client/src/features/rundown/RundownList.tsx @@ -1,16 +1,23 @@ +import { Playback } from 'ontime-types'; import { memo } from 'react'; import Empty from '../../common/components/state/Empty'; -import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; +import { useContextRundownList } from '../../common/hooks-query/useContextRundown'; import { useRundownEditor } from '../../common/hooks/useSocket'; import Rundown from './Rundown'; +const backgroundFeatureData = { + playback: Playback.Stop, + selectedEventId: null, + nextEventId: null, +}; + export default memo(RundownList); function RundownList() { - const { data, status, rundownMetadata } = useRundownWithMetadata(); + const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList(); const featureData = useRundownEditor(); - const isLoading = status !== 'success' || !data || !rundownMetadata; + const isLoading = status !== 'success' || !rundown || !rundownMetadata; if (isLoading) { return ; @@ -18,12 +25,12 @@ function RundownList() { return ( ); } diff --git a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx index 2384186d25..b5e14911f6 100644 --- a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx @@ -1,7 +1,7 @@ import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { useMemo } from 'react'; -import useRundown from '../../../common/hooks-query/useRundown'; +import { useRundown } from '../../../common/hooks-query/useRundown'; import { useEventSelection } from '../useEventSelection'; import EventEditorFooter from './composite/EventEditorFooter'; import EventEditor from './EventEditor'; @@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss'; export default function RundownEntryEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); - const { data } = useRundown(); + const { data } = useRundown(null); const entry = useMemo(() => { if (data.order.length === 0) { diff --git a/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx b/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx index 682f1290c7..724bc1f464 100644 --- a/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx +++ b/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx @@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button'; import Dialog from '../../../common/components/dialog/Dialog'; import Input from '../../../common/components/input/input/Input'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; -import useRundown from '../../../common/hooks-query/useRundown'; +import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown'; import { orderEntries } from '../rundown.utils'; import { useEventSelection } from '../useEventSelection'; @@ -17,8 +17,7 @@ type RenumberCueData = Pick; export default function RenumberCuesDialog() { 'use memo'; - const { data } = useRundown(); - const { flatOrder } = data; + const { flatOrder } = useContextRundownCueRenumberModal(); const { onClose, isOpen } = useRenumberCuesDialogStore(); const { renumberCues } = useEntryActionsContext(); const selectedEvents = useEventSelection((state) => state.selectedEvents); diff --git a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx index b7a37bd0f8..e943f3b98c 100644 --- a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx +++ b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx @@ -1,10 +1,7 @@ import { memo, useEffect, useMemo } from 'react'; import EmptyPage from '../../../common/components/state/EmptyPage'; -import { EntryActionsProvider } from '../../../common/context/EntryActionsContext'; import useCustomFields from '../../../common/hooks-query/useCustomFields'; -import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown'; -import { useEntryActions } from '../../../common/hooks/useEntryAction'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; @@ -16,8 +13,6 @@ function RundownTable() { const { data: customFields, status: customFieldStatus } = useCustomFields(); const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const { editorMode } = useEditorFollowMode(); - const source = useLoadedRundownSource(); - const actions = useEntryActions(); // Editor always has full permissions useEffect(() => { @@ -35,14 +30,12 @@ function RundownTable() { const isLoading = !customFields || customFieldStatus === 'pending'; return ( - - - {isLoading ? ( - - ) : ( - - )} - - + + {isLoading ? ( + + ) : ( + + )} + ); } diff --git a/apps/client/src/views/cuesheet/CuesheetPage.module.scss b/apps/client/src/views/cuesheet/CuesheetPage.module.scss index 64ca0dbf2c..da4d26a5b4 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.module.scss +++ b/apps/client/src/views/cuesheet/CuesheetPage.module.scss @@ -14,7 +14,3 @@ 'table'; color: $ui-white; } - -.rundownSelect { - min-width: min(20rem, calc(100vw - 6rem)); -} diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index d2febeb17e..cc31f5d2ac 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -4,51 +4,46 @@ import { IoApps } from 'react-icons/io5'; import IconButton from '../../common/components/buttons/IconButton'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; -import { useScopedRundown } from '../../common/hooks-query/useScopedRundown'; -import { useScopedEntryActions } from '../../common/hooks/useEntryAction'; +import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { getIsNavigationLocked } from '../../externals'; import CuesheetOverview from '../../features/overview/CuesheetOverview'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetTableWrapper from './CuesheetTableWrapper'; -import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection'; import styles from './CuesheetPage.module.scss'; export default function CuesheetPage() { 'use memo'; const [isMenuOpen, menuHandler] = useDisclosure(); - const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection(); - const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId); - - const actions = useScopedEntryActions(source.rundownId); useWindowTitle('Cuesheet'); const isLocked = getIsNavigationLocked(); return ( - - - -
- - {!isLocked && ( - - - - )} - - - -
-
+ + + + +
+ + {!isLocked && ( + + + + )} + + + +
+
+
); } diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 595e221ba2..12a71ce342 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -1,39 +1,20 @@ -import { MaybeString, ProjectRundown } from 'ontime-types'; import { memo, use, useMemo } from 'react'; -import Select from '../../common/components/select/Select'; import EmptyPage from '../../common/components/state/EmptyPage'; import { PresetContext } from '../../common/context/PresetContext'; +import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext'; import useCustomFields from '../../common/hooks-query/useCustomFields'; -import type { RundownSource } from '../../common/hooks-query/useScopedRundown'; -import { AppMode } from '../../ontimeConfig'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import CuesheetTable from './cuesheet-table/CuesheetTable'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; -import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection'; - -import styles from './CuesheetPage.module.scss'; - -interface CuesheetTableWrapperProps { - source: RundownSource; - selectedRundownId: MaybeString; - loadedRundownId: string; - setSelectedRundownId: (rundownId: string) => void; - projectRundowns: ProjectRundown[]; -} export default memo(CuesheetTableWrapper); -function CuesheetTableWrapper({ - source, - selectedRundownId, - setSelectedRundownId, - loadedRundownId, - projectRundowns, -}: CuesheetTableWrapperProps) { +function CuesheetTableWrapper() { const preset = use(PresetContext); - const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; - const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); + const { isLoadedRundown } = useRundownSelectionContext(); + + const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown }); const { data: customFields, status: customFieldStatus } = useCustomFields(); const columns = useMemo( @@ -50,66 +31,12 @@ function CuesheetTableWrapper({ ) : ( - - - } + isCurrentRundown={isLoadedRundown} /> )} ); } - -interface RundownSelectProps { - cuesheetMode: AppMode; - selectedRundownId: MaybeString; - loadedRundownId: string; - setSelectedRundownId: (rundownId: string) => void; - projectRundowns: ProjectRundown[]; -} - -function RundownSelect({ - cuesheetMode, - projectRundowns, - loadedRundownId, - selectedRundownId, - setSelectedRundownId, -}: RundownSelectProps) { - 'use memo'; - const options = projectRundowns.map(({ id, title }) => ({ - value: id, - label: loadedRundownId === id ? `${title} (loaded)` : title, - })); - options.unshift({ - value: FOLLOW_LOADED_RUNDOWN_ID, - label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation - }); - - return ( -
- { + if (value === FOLLOW) selectRundownId(null); + else selectRundownId(value); + }} + disabled={cuesheetMode === AppMode.Run} + fluid + /> +
+ ); +} diff --git a/apps/client/src/views/editor/title-list/TitleList.tsx b/apps/client/src/views/editor/title-list/TitleList.tsx index 1082a86777..8f2abd8940 100644 --- a/apps/client/src/views/editor/title-list/TitleList.tsx +++ b/apps/client/src/views/editor/title-list/TitleList.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import ScrollArea from '../../../common/components/scroll-area/ScrollArea'; -import useRundown from '../../../common/hooks-query/useRundown'; +import { useRundown } from '../../../common/hooks-query/useRundown'; import { useSelectedEventId } from '../../../common/hooks/useSocket'; import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata'; import { useEventSelection } from '../../../features/rundown/useEventSelection'; @@ -20,7 +20,7 @@ interface TitleListProps { } export default function TitleList({ mode }: TitleListProps) { - const { data: rundown } = useRundown(); + const { data: rundown } = useRundown(null); const selectedEventId = useSelectedEventId(); const cursor = useEventSelection((state) => state.cursor); diff --git a/apps/client/src/views/timer/useTimerData.ts b/apps/client/src/views/timer/useTimerData.ts index 406912e0b3..173787b5ed 100644 --- a/apps/client/src/views/timer/useTimerData.ts +++ b/apps/client/src/views/timer/useTimerData.ts @@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro import useCustomFields from '../../common/hooks-query/useCustomFields'; import useProjectData from '../../common/hooks-query/useProjectData'; -import useRundown from '../../common/hooks-query/useRundown'; +import { useRundown } from '../../common/hooks-query/useRundown'; import useSettings from '../../common/hooks-query/useSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings'; import { useViewOptionsStore } from '../../common/stores/viewOptions'; @@ -26,7 +26,7 @@ export function useTimerData(): ViewData { const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const { data: settings, status: settingsStatus } = useSettings(); const { data: customFields, status: customFieldsStatus } = useCustomFields(); - const { data: rundown, status: rundownStatus } = useRundown(); + const { data: rundown, status: rundownStatus } = useRundown(null); const { entries } = rundown; return { diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 994b43c70c..45c3b44377 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -15,16 +15,18 @@ import { createNewRundown, deleteAllEntries, deleteEntries, + deleteRundown, + duplicateRundown, editEntry, groupEntries, - initRundown, loadRundown, + renameRundown, renumberEntries, reorderEntry, swapEvents, ungroupEntries, } from './rundown.service.js'; -import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js'; +import { normalisedToRundownArray } from './rundown.utils.js'; import { clonePostValidator, entryBatchPutValidator, @@ -34,6 +36,7 @@ import { entryReorderValidator, entrySwapValidator, rundownArrayOfIds, + rundownPatchValidator, rundownPostValidator, } from './rundown.validation.js'; @@ -104,12 +107,7 @@ router.post( paramsWithId, async (req: Request, res: Response) => { try { - const dataProvider = getDataProvider(); - const rundown = dataProvider.getRundown(req.params.id); - - const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`); - await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown); - + await duplicateRundown(req.params.id) const projectRundowns = getDataProvider().getProjectRundowns(); res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); } catch (error) { @@ -123,53 +121,27 @@ router.post( * Patches the data of an existing rundown * Currently only the title can be changed */ -router.patch('/:id', paramsWithId, async (req: Request, res: Response) => { - try { - const dataProvider = getDataProvider(); - const rundown = dataProvider.getRundown(req.params.id); - if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`); - if (!req.body.title) throw new Error('No title provided'); - - await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title }); - - /** - * If loaded we re-init the rundown - * This is likely over-kill but the simplest way to ensure state consistency - */ - if (req.params.id === getCurrentRundown().id) { - const rundown = dataProvider.getRundown(req.params.id); - const customField = dataProvider.getCustomFields(); - await initRundown(rundown, customField); +router.patch( + '/:id', + rundownPatchValidator, + async (req: Request, res: Response) => { + try { + await renameRundown(req.params.id, req.body.title); + const projectRundowns = getDataProvider().getProjectRundowns(); + res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); } - - const projectRundowns = getDataProvider().getProjectRundowns(); - res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); + }, +); /** * Deletes a rundown if not loaded */ router.delete('/:id', paramsWithId, async (req: Request, res: Response) => { try { - if (req.params.id === getCurrentRundown().id) { - res.status(400).send({ message: 'Cannot delete loaded rundown' }); - return; - } - - const dataProvider = getDataProvider(); - const projectRundowns = dataProvider.getProjectRundowns(); - - if (Object.keys(projectRundowns).length <= 1) { - // might never hit this as it is likely covered by the case of trying to delete the loaded rundown - res.status(400).send({ message: 'Cannot delete the last rundown' }); - return; - } - - await dataProvider.deleteRundown(req.params.id); + await deleteRundown(req.params.id); const newProjectRundowns = getDataProvider().getProjectRundowns(); res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) }); } catch (error) { diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 07c03ab261..64f7292ad8 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -16,7 +16,7 @@ import { isOntimeEvent, isOntimeGroup, } from 'ontime-types'; -import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils'; +import { customFieldLabelToKey, generateId, getInsertAfterId, resolveInsertParent } from 'ontime-utils'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; @@ -692,3 +692,72 @@ export async function createNewRundown(title: string) { return projectRundowns; } + +/** + * duplicate a rundown + * @param id + * @throws + */ +export async function duplicateRundown(id: string) { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + + const newRundownId = generateId(); + const newRundown: Rundown = structuredClone(rundown); + newRundown.id = newRundownId; + newRundown.title = `Copy of ${rundown.title}`; + newRundown.revision = 0; + + await dataProvider.setRundown(newRundownId, newRundown); + + setImmediate(() => { + sendRefetch(RefetchKey.ProjectRundowns); + }); +} + + +/** + * rename a rundown + * @param id + * @throws + */ +export async function renameRundown(id: string, title: string) { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + await dataProvider.setRundown(rundown.id, { ...rundown, title }); + + /** + * If we are modifying the loaded rundown we re-init it + * This is likely over-kill but the simplest way to ensure state consistency + */ + if (isCurrentRundown(id)) { + const rundown = dataProvider.getRundown(id); + const customField = dataProvider.getCustomFields(); + await initRundown(rundown, customField); + } + + setImmediate(() => { + sendRefetch(RefetchKey.ProjectRundowns); + }); +} + +/** + * delete a rundown + * @param id + * @throws + */ +export async function deleteRundown(id: string) { + if (isCurrentRundown(id)) throw new Error('Cannot delete loaded rundown'); + + const dataProvider = getDataProvider(); + const projectRundowns = dataProvider.getProjectRundowns(); + + // might never hit this as it is likely covered by the case of trying to delete the loaded rundown + if (Object.keys(projectRundowns).length <= 1) throw new Error('Cannot delete the last rundown'); + + await dataProvider.deleteRundown(id); + + setImmediate(() => { + sendRefetch(RefetchKey.ProjectRundowns); + }); +} diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 740f56cba9..f796b3a47d 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -460,20 +460,6 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund }); } -/** - * Duplicates an existing rundown ensuring all IDs are unique - */ -export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown { - const newRundownId = generateId(); - - const newRundown = structuredClone(rundown); - newRundown.id = newRundownId; - newRundown.title = newTitle; - newRundown.revision = 0; - - return newRundown; -} - export type IncrementNumber = { integer: number; faction: number; diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 67b292279f..1ef62aaed7 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -5,6 +5,11 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio // #region operations on project rundowns ========================= export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction]; +export const rundownPatchValidator = [ + param('id').isString().trim().notEmpty(), + body('title').isString().trim().notEmpty().withMessage('No title provided'), + requestValidationFunction, +]; // #endregion operations on project rundowns ====================== // #region operations on rundown entries ==========================