refactor: allow secondary rundowns (#2086)

* refactor: allow secondary rundowns

* ui: move dropdown

* feat: follow loaded

* ui: background edit warning

ui: fix disable radio button

* feat(ui): add loaded sufix in the rundown list

* feat(ui): add direct link to background edit from rundown manager

* fix: better fallback

* fix: default to is isCurrentRundown for nav bar colour

* chore: rename navigate to cuesheet

---------

Co-authored-by: alex-arc <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2026-06-07 13:33:38 +02:00
committed by GitHub
parent 199dea5df6
commit 0e6d6cd416
33 changed files with 949 additions and 515 deletions
+22 -11
View File
@@ -34,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
throw new Error('Invalid rundown payload');
}
return res.data;
}
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
/**
* HTTP request to fetch all entries in the given rundown
*/
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
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<AxiosRespo
}
// #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
@@ -12,6 +12,7 @@ import {
renameRundown,
} from '../api/rundown';
//TODO: make suspends so we don't have to deal with no value all over
/**
* Project rundowns
*/
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
};
/**
* Normalised rundown data
* 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`.
*/
export default function useRundown() {
const queryClient = useQueryClient();
@@ -34,7 +38,7 @@ export default function useRundown() {
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
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<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -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],
);
}
+15 -8
View File
@@ -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<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (loadedRundownId) {
return getRundownQueryKey(loadedRundownId);
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
return CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient]);
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(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
+7 -2
View File
@@ -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;
}