diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index fa2381ab92..1f9d376f81 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -34,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise { + const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal }); + if (!isValidRundown(res.data)) { + throw new Error('Invalid rundown payload'); } + return res.data; } /** @@ -197,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise { if (!data || loadedRundownId) return; queryClient.setQueryData(getRundownQueryKey(data.id), data); @@ -115,3 +119,21 @@ export function useRundownAuxData() { }, [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 new file mode 100644 index 0000000000..a14707d7ff --- /dev/null +++ b/apps/client/src/common/hooks-query/useScopedRundown.ts @@ -0,0 +1,54 @@ +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/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 3eba250862..fc040b3a75 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -63,9 +63,16 @@ export type EventOptions = Partial<{ }>; /** - * Gather utilities for actions on entries + * Gather utilities for actions on entries in the loaded rundown. */ -export const useEntryActions = () => { +export const useEntryActions = () => useEntryActionsForRundown(undefined); + +/** + * Gather utilities for actions on entries in an explicitly selected rundown. + */ +export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? ''); + +function useEntryActionsForRundown(scopedRundownId: string | undefined) { const queryClient = useQueryClient(); const { linkPrevious, @@ -78,12 +85,12 @@ export const useEntryActions = () => { } = useEditorSettings(); const resolveCurrentRundownQueryKey = useCallback(() => { - const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; - if (loadedRundownId) { - return getRundownQueryKey(loadedRundownId); + if (scopedRundownId !== undefined) { + return getRundownQueryKey(scopedRundownId); } - return CURRENT_RUNDOWN_QUERY_KEY; - }, [queryClient]); + const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; + return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; + }, [queryClient, scopedRundownId]); /** * Returns the currently loaded rundown @@ -1004,7 +1011,7 @@ export const useEntryActions = () => { updateTimer, ], ); -}; +} /** * Utility to optimistically delete entries from client cache diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 7eef679668..a9366cc364 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -14,8 +14,8 @@ import { APP_SETTINGS, CLIENT_LIST, CSS_OVERRIDE, - CUSTOM_FIELDS, CURRENT_RUNDOWN_QUERY_KEY, + CUSTOM_FIELDS, PROJECT_DATA, REPORT, RUNDOWN, @@ -213,6 +213,9 @@ export const connectSocket = () => { case RefetchKey.Settings: ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS }); break; + case RefetchKey.ProjectRundowns: + ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS }); + break; default: { target satisfies never; break; @@ -242,7 +245,9 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s // skip if we dont recognise the ID the revision is lower const queryKey = getRundownQueryKey(rundownId); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); - if (revision !== null && revision === cachedRundown?.revision) { + + if (revision === cachedRundown?.revision) { + // we already have the latest change return; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx index c3c6562b93..3018cd9162 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx @@ -9,6 +9,7 @@ import { IoPencilOutline, IoTrash, } from 'react-icons/io5'; +import { TbGhost3 } from 'react-icons/tb'; import { downloadAsExcel } from '../../../../common/api/excel'; import { maybeAxiosError } from '../../../../common/api/utils'; @@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo import Tag from '../../../../common/components/tag/Tag'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { cx } from '../../../../common/utils/styleUtils'; +import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection'; import * as Panel from '../../panel-utils/PanelUtils'; import RundownRenameForm from './composite/RundownRenameForm'; import { ManageRundownForm } from './ManageRundownForm'; @@ -97,6 +99,8 @@ export default function ManageRundowns() { await downloadAsExcel(rundownId, title); }; + const navigateToCuesheet = useDirectLinkToBackgroundEdit(); + return ( <> @@ -180,6 +184,12 @@ export default function ManageRundowns() { label: 'Duplicate', onClick: () => submitRundownDuplicate(id), }, + { + type: 'item', + icon: TbGhost3, + label: 'Edit in cuesheet', + onClick: () => navigateToCuesheet(id), + }, { type: 'divider' }, { type: 'destructive', diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index bfe6ae1c6a..0fcebf4158 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -6,6 +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 { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { handleLinks } from '../../common/utils/linkUtils'; @@ -104,6 +105,8 @@ interface RundownRootProps { } function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { + const source = useLoadedRundownSource(); + return (
{isSmallDevice ? ( @@ -112,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/entry-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx index b9f8896a21..5c630b3d2d 100644 --- a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx @@ -1,7 +1,6 @@ -import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; +import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { useMemo } from 'react'; -import useRundown from '../../../common/hooks-query/useRundown'; import EventEditor from './EventEditor'; import GroupEditor from './GroupEditor'; import MilestoneEditor from './MilestoneEditor'; @@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss'; interface CuesheetEntryEditorProps { entryId: string; + rundown: Rundown; } -export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) { - const { data } = useRundown(); - +export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) { const entry = useMemo(() => { - if (data.order.length === 0) { + if (rundown.order.length === 0) { return null; } - const event = data.entries[entryId]; + const event = rundown.entries[entryId]; return event ?? null; - }, [entryId, data.order, data.entries]); + }, [entryId, rundown.entries, rundown.order.length]); if (isOntimeEvent(entry)) { return ( diff --git a/apps/client/src/features/rundown/rundown-table/EditorTableSettings.tsx b/apps/client/src/features/rundown/rundown-table/EditorTableSettings.tsx deleted file mode 100644 index 9a9d3768b1..0000000000 --- a/apps/client/src/features/rundown/rundown-table/EditorTableSettings.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Toolbar } from '@base-ui/react/toolbar'; -import type { Column } from '@tanstack/react-table'; - -import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; -import { - ColumnSettings, - ViewSettings, -} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings'; -import { usePersistedRundownOptions } from '../rundown.options'; - -import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss'; - -interface EditorTableSettingsProps { - columns: Column[]; - handleResetResizing: () => void; - handleResetReordering: () => void; - handleClearToggles: () => void; -} - -export default function EditorTableSettings({ - columns, - handleResetResizing, - handleResetReordering, - handleClearToggles, -}: EditorTableSettingsProps) { - const options = usePersistedRundownOptions(); - - return ( - - - - - ); -} diff --git a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx index e943f3b98c..b7a37bd0f8 100644 --- a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx +++ b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx @@ -1,7 +1,10 @@ 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'; @@ -13,6 +16,8 @@ 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(() => { @@ -30,12 +35,14 @@ function RundownTable() { const isLoading = !customFields || customFieldStatus === 'pending'; return ( - - {isLoading ? ( - - ) : ( - - )} - + + + {isLoading ? ( + + ) : ( + + )} + + ); } diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index be4ba6a1a4..180f525f25 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro import { MouseEvent } from 'react'; import { create } from 'zustand'; -import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants'; +import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants'; import { ontimeQueryClient } from '../../common/queryClient'; import { isMacOS } from '../../common/utils/deviceUtils'; @@ -137,12 +137,9 @@ export const useEventSelection = create()((set, get) => ({ })); function getLoadedRundownData() { - const loadedRundownId = ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; - if (loadedRundownId) { - return ontimeQueryClient.getQueryData(getRundownQueryKey(loadedRundownId)); - } - - return ontimeQueryClient.getQueryData(CURRENT_RUNDOWN_QUERY_KEY); + const rundownId = ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; + if (!rundownId) return undefined; + return ontimeQueryClient.getQueryData(getRundownQueryKey(rundownId)); } export function getSelectionMode(event: MouseEvent): SelectionMode { diff --git a/apps/client/src/views/cuesheet/CuesheetPage.module.scss b/apps/client/src/views/cuesheet/CuesheetPage.module.scss index da4d26a5b4..64ca0dbf2c 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.module.scss +++ b/apps/client/src/views/cuesheet/CuesheetPage.module.scss @@ -14,3 +14,7 @@ '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 5fa831731a..d2febeb17e 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -4,28 +4,34 @@ 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 { useEntryActions } from '../../common/hooks/useEntryAction'; +import { useScopedRundown } from '../../common/hooks-query/useScopedRundown'; +import { useScopedEntryActions } from '../../common/hooks/useEntryAction'; 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 entryActions = useEntryActions(); + 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 && ( @@ -35,7 +41,13 @@ export default function CuesheetPage() { )} - +
); diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 365c9fdf01..595e221ba2 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -1,18 +1,40 @@ +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 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() { - const { data: customFields, status: customFieldStatus } = useCustomFields(); +function CuesheetTableWrapper({ + source, + selectedRundownId, + setSelectedRundownId, + loadedRundownId, + projectRundowns, +}: CuesheetTableWrapperProps) { const preset = use(PresetContext); - const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset); + const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; + const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); + const { data: customFields, status: customFieldStatus } = useCustomFields(); const columns = useMemo( () => makeCuesheetColumns(customFields, cuesheetMode, preset), @@ -28,11 +50,66 @@ function CuesheetTableWrapper() { ) : ( + + + } /> )} ); } + +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 ( +
+