From 8c14c330f5302bd74e2291b865ef45601bb97b10 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:58:58 +0200 Subject: [PATCH] refactor: cache is rundown aware --- apps/client/src/common/api/constants.ts | 3 +- .../src/common/hooks-query/useRundown.ts | 25 ++- .../client/src/common/hooks/useEntryAction.ts | 155 ++++++++++-------- apps/client/src/common/utils/socket.ts | 43 ++++- .../sources-panel/useGoogleSheet.ts | 16 +- .../src/features/rundown/useEventSelection.ts | 17 +- .../features/214-rundown-switch-edit.spec.ts | 79 +++++++++ 7 files changed, 253 insertions(+), 85 deletions(-) create mode 100644 e2e/tests/features/214-rundown-switch-edit.spec.ts diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index e20042509..ad1f583cc 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -11,7 +11,8 @@ export const CUSTOM_VIEWS = ['customViews']; export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; export const PROJECT_RUNDOWNS = ['projectRundowns']; -export const RUNDOWN = ['rundown']; +export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current']; +export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId]; export const RUNTIME = ['runtimeStore']; export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index a35cda70a..84ba8c57a 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,12 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; -import { RUNDOWN } from '../api/constants'; +import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { fetchCurrentRundown } from '../api/rundown'; import { useSelectedEventId } from '../hooks/useSocket'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; +import { useProjectRundowns } from './useProjectRundowns'; // revision is -1 so that the remote revision is higher const cachedRundownPlaceholder: Rundown = { @@ -22,12 +23,28 @@ const cachedRundownPlaceholder: Rundown = { * Normalised rundown data */ export default function useRundown() { + const queryClient = useQueryClient(); + const { + data: { loaded: loadedRundownId }, + } = useProjectRundowns(); + const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: RUNDOWN, + queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, queryFn: ({ signal }) => fetchCurrentRundown({ signal }), refetchInterval: queryRefetchIntervalSlow, }); + // Seed the ID-based cache when fetching via the 'current' alias (bootstrap) + useEffect(() => { + if (!data || loadedRundownId) return; + queryClient.setQueryData(getRundownQueryKey(data.id), data); + }, [data, loadedRundownId, queryClient]); + + useEffect(() => { + if (!loadedRundownId) return; + queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); + }, [loadedRundownId, queryClient]); + return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; } diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 93bb7d094..ea27b04c7 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -9,6 +9,7 @@ import { OntimeGroup, OntimeMilestone, PatchWithId, + ProjectRundownsList, Rundown, SupportedEntry, TimeField, @@ -33,7 +34,7 @@ import { import { useCallback, useMemo } from 'react'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; -import { RUNDOWN } from '../api/constants'; +import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants'; import { ReorderEntry, deleteEntries, @@ -75,12 +76,20 @@ export const useEntryActions = () => { defaultEndAction, } = useEditorSettings(); + const resolveCurrentRundownQueryKey = useCallback(() => { + const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; + if (loadedRundownId) { + return getRundownQueryKey(loadedRundownId); + } + return CURRENT_RUNDOWN_QUERY_KEY; + }, [queryClient]); + /** * Returns the currently loaded rundown */ const getCurrentRundownData = useCallback(() => { - return queryClient.getQueryData(RUNDOWN); - }, [queryClient]); + return queryClient.getQueryData(resolveCurrentRundownQueryKey()); + }, [queryClient, resolveCurrentRundownQueryKey]); /** * Looks for an entry with a given ID in the currently loaded rundown @@ -103,9 +112,10 @@ export const useEntryActions = () => { const { mutateAsync: addEntryMutation } = useMutation({ mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry), onMutate: async ([_rundownId, entry]) => { - await queryClient.cancelQueries({ queryKey: RUNDOWN }); + const queryKey = resolveCurrentRundownQueryKey(); + await queryClient.cancelQueries({ queryKey }); - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(queryKey); if (previousData) { const optimisticEntry = createOptimisticEntry(entry); @@ -133,31 +143,31 @@ export const useEntryActions = () => { parent ? (newRundown.entries[parent.id] as OntimeGroup) : null, ); - queryClient.setQueryData(RUNDOWN, newRundown); + queryClient.setQueryData(queryKey, newRundown); } - return { previousData }; + return { previousData, queryKey }; }, - onSuccess: (response) => { - if (!response.data) return; + onSuccess: (response, _variables, context) => { + if (!response.data || !context?.queryKey) return; const serverEntry = response.data; - const currentData = queryClient.getQueryData(RUNDOWN); + const currentData = queryClient.getQueryData(context.queryKey); if (currentData) { - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(context.queryKey, { ...currentData, entries: { ...currentData.entries, [serverEntry.id]: serverEntry }, }); } }, onError: (_error, _variables, context) => { - if (context?.previousData) { - queryClient.setQueryData(RUNDOWN, context.previousData); + if (context?.previousData && context?.queryKey) { + queryClient.setQueryData(context.queryKey, context.previousData); } }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); + onSettled: (_data, _error, _variables, context) => { + queryClient.invalidateQueries({ queryKey: context?.queryKey ?? resolveCurrentRundownQueryKey() }); }, }); @@ -247,8 +257,8 @@ export const useEntryActions = () => { const { mutateAsync: cloneEntryMutation } = useMutation({ mutationFn: ([rundownId, entryId, options]: Parameters) => postCloneEntry(rundownId, entryId, options), - onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + onMutate: () => queryClient.cancelQueries({ queryKey: resolveCurrentRundownQueryKey() }), + onSettled: () => queryClient.invalidateQueries({ queryKey: resolveCurrentRundownQueryKey() }), }); /** @@ -278,11 +288,12 @@ export const useEntryActions = () => { mutationFn: ([rundownId, newEvent]: Parameters) => putEditEntry(rundownId, newEvent), // we optimistically update here onMutate: async ([_rundownId, newEvent]) => { + const queryKey = resolveCurrentRundownQueryKey(); // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); + await queryClient.cancelQueries({ queryKey }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(queryKey); const eventId = newEvent.id; if (previousData && eventId) { @@ -290,7 +301,7 @@ export const useEntryActions = () => { const newRundown = { ...previousData.entries }; // @ts-expect-error -- we expect the events to be of same type newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(queryKey, { id: previousData.id, title: previousData.title, order: previousData.order, @@ -301,18 +312,18 @@ export const useEntryActions = () => { } // Return a context with the previous and new events - return { previousData, newEvent }; + return { previousData, newEvent, queryKey }; }, // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { - if (context?.previousData) { - queryClient.setQueryData(RUNDOWN, context?.previousData); + if (context?.previousData && context?.queryKey) { + queryClient.setQueryData(context.queryKey, context.previousData); } }, // Mutation finished, failed or successful // Fetch anyway, just to be sure - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: RUNDOWN }); + onSettled: async (_data, _error, _variables, context) => { + await queryClient.invalidateQueries({ queryKey: context?.queryKey ?? resolveCurrentRundownQueryKey() }); }, }); @@ -409,7 +420,7 @@ export const useEntryActions = () => { * Utility function to get the previous event end time */ function getPreviousEnd(): number { - const cachedRundown = queryClient.getQueryData(RUNDOWN); + const cachedRundown = queryClient.getQueryData(resolveCurrentRundownQueryKey()); if (!cachedRundown?.order || !cachedRundown?.entries) { return 0; @@ -440,11 +451,12 @@ export const useEntryActions = () => { const { mutateAsync: batchUpdateEventsMutation } = useMutation({ mutationFn: ([rundownId, data]: Parameters) => putBatchEditEvents(rundownId, data), onMutate: async ([_rundownId, data]) => { + const queryKey = resolveCurrentRundownQueryKey(); // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); + await queryClient.cancelQueries({ queryKey }); // Snapshot the previous value - const previousRundown = queryClient.getQueryData(RUNDOWN); + const previousRundown = queryClient.getQueryData(queryKey); if (previousRundown) { const eventIds = new Set(data.ids); @@ -462,7 +474,7 @@ export const useEntryActions = () => { } }); - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(queryKey, { id: previousRundown.id, title: previousRundown.title, order: previousRundown.order, @@ -473,13 +485,13 @@ export const useEntryActions = () => { } // Return a context with the previous rundown - return { previousRundown }; + return { previousRundown, queryKey }; }, - onSuccess: (response) => { - if (!response.data) return; + onSuccess: (response, _variables, context) => { + if (!response.data || !context?.queryKey) return; const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(context.queryKey, { id, title, order, @@ -489,7 +501,9 @@ export const useEntryActions = () => { }); }, onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousRundown); + if (context?.queryKey) { + queryClient.setQueryData(context.queryKey, context?.previousRundown); + } }, }); @@ -517,17 +531,18 @@ export const useEntryActions = () => { mutationFn: ([rundownId, entryIds]: Parameters) => deleteEntries(rundownId, entryIds), // we optimistically update here onMutate: async ([_rundownId, entryIds]) => { + const queryKey = resolveCurrentRundownQueryKey(); // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); + await queryClient.cancelQueries({ queryKey }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(queryKey); if (previousData) { // optimistically update object const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData); - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(queryKey, { id: previousData.id, title: previousData.title, order, @@ -538,17 +553,19 @@ export const useEntryActions = () => { } // Return a context with the previous and new events - return { previousData }; + return { previousData, queryKey }; }, // Mutation fails, rollback undoes optimist update onError: (_error, _entryIds, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + if (context?.queryKey) { + queryClient.setQueryData(context.queryKey, context?.previousData); + } }, // Mutation finished, failed or successful // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); + onSettled: (_data, _error, _variables, context) => { + queryClient.invalidateQueries({ queryKey: context?.queryKey ?? resolveCurrentRundownQueryKey() }); }, }); @@ -579,14 +596,15 @@ export const useEntryActions = () => { mutationFn: ([rundownId]: Parameters) => requestDeleteAll(rundownId), // we optimistically update here onMutate: async () => { + const queryKey = resolveCurrentRundownQueryKey(); // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); + await queryClient.cancelQueries({ queryKey }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(queryKey); // optimistically update object - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(queryKey, { id: previousData?.id ?? 'default', title: previousData?.title ?? '', order: [], @@ -596,17 +614,19 @@ export const useEntryActions = () => { }); // Return a context with the previous and new events - return { previousData }; + return { previousData, queryKey }; }, // Mutation fails, rollback optimist update onError: (_error, _, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + if (context?.queryKey) { + queryClient.setQueryData(context.queryKey, context?.previousData); + } }, // Mutation finished, failed or successful // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); + onSettled: (_data, _error, _variables, context) => { + queryClient.invalidateQueries({ queryKey: context?.queryKey ?? resolveCurrentRundownQueryKey() }); }, }); @@ -632,12 +652,12 @@ export const useEntryActions = () => { */ const { mutateAsync: applyDelayMutation } = useMutation({ mutationFn: ([rundownId, delayId]: Parameters) => requestApplyDelay(rundownId, delayId), - onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), + onMutate: () => queryClient.cancelQueries({ queryKey: resolveCurrentRundownQueryKey() }), onSuccess: (response) => { if (!response.data) return; const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(resolveCurrentRundownQueryKey(), { id, title, order, @@ -648,7 +668,7 @@ export const useEntryActions = () => { }, // Mutation finished, failed or successful onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); + queryClient.invalidateQueries({ queryKey: resolveCurrentRundownQueryKey() }); }, }); @@ -677,12 +697,12 @@ export const useEntryActions = () => { */ const { mutateAsync: ungroupMutation } = useMutation({ mutationFn: ([rundownId, groupId]: Parameters) => requestUngroup(rundownId, groupId), - onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), + onMutate: () => queryClient.cancelQueries({ queryKey: resolveCurrentRundownQueryKey() }), onSuccess: (response) => { if (!response.data) return; const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(resolveCurrentRundownQueryKey(), { id, title, order, @@ -691,7 +711,7 @@ export const useEntryActions = () => { revision, }); }, - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + onSettled: () => queryClient.invalidateQueries({ queryKey: resolveCurrentRundownQueryKey() }), }); /** @@ -720,12 +740,12 @@ export const useEntryActions = () => { const { mutateAsync: groupEntriesMutation } = useMutation({ mutationFn: ([rundownId, entryIds]: Parameters) => requestGroupEntries(rundownId, entryIds), - onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), + onMutate: () => queryClient.cancelQueries({ queryKey: resolveCurrentRundownQueryKey() }), onSuccess: (response) => { if (!response.data) return; const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(resolveCurrentRundownQueryKey(), { id, title, order, @@ -734,7 +754,7 @@ export const useEntryActions = () => { revision, }); }, - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + onSettled: () => queryClient.invalidateQueries({ queryKey: resolveCurrentRundownQueryKey() }), }); /** @@ -771,9 +791,9 @@ export const useEntryActions = () => { */ const { mutateAsync: reorderEntryMutation } = useMutation({ mutationFn: ([rundownId, data]: Parameters) => patchReorderEntry(rundownId, data), - onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), + onMutate: () => queryClient.cancelQueries({ queryKey: resolveCurrentRundownQueryKey() }), onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); + queryClient.invalidateQueries({ queryKey: resolveCurrentRundownQueryKey() }); }, }); @@ -848,11 +868,12 @@ export const useEntryActions = () => { mutationFn: ([rundownId, from, to]: Parameters) => requestEventSwap(rundownId, from, to), // we optimistically update here onMutate: async ([_rundownId, from, to]) => { + const queryKey = resolveCurrentRundownQueryKey(); // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); + await queryClient.cancelQueries({ queryKey }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(queryKey); if (previousData) { // optimistically update object const newRundown = { ...previousData.entries }; @@ -867,7 +888,7 @@ export const useEntryActions = () => { newRundown[from] = newA; newRundown[to] = newB; - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(queryKey, { id: previousData.id, title: previousData.title, order: previousData.order, @@ -878,17 +899,19 @@ export const useEntryActions = () => { } // Return a context with the previous events - return { previousData }; + return { previousData, queryKey }; }, // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + if (context?.queryKey) { + queryClient.setQueryData(context.queryKey, context?.previousData); + } }, // Mutation finished, failed or successful // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); + onSettled: (_data, _error, _variables, context) => { + queryClient.invalidateQueries({ queryKey: context?.queryKey ?? resolveCurrentRundownQueryKey() }); }, }); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index ce7520e98..b000f935f 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,7 +1,9 @@ import { ApiActionTag, Log, + MaybeNumber, MessageTag, + ProjectRundownsList, RefetchKey, Rundown, RuntimeStore, @@ -16,12 +18,14 @@ import { CSS_OVERRIDE, CUSTOM_FIELDS, PROJECT_DATA, + CURRENT_RUNDOWN_QUERY_KEY, + PROJECT_RUNDOWNS, REPORT, - RUNDOWN, RUNTIME, TRANSLATION, URL_PRESETS, VIEW_SETTINGS, + getRundownQueryKey, } from '../api/constants'; import { invalidateAllCaches } from '../api/utils'; import { ontimeQueryClient } from '../queryClient'; @@ -189,11 +193,10 @@ export const connectSocket = () => { case RefetchKey.Report: ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); break; - case RefetchKey.Rundown: - if (revision === (ontimeQueryClient.getQueryData(RUNDOWN) as Rundown).revision) break; - ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); - ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); + case RefetchKey.Rundown: { + maybeInvalidateRundownCache(revision); break; + } case RefetchKey.UrlPresets: ontimeQueryClient.invalidateQueries({ queryKey: URL_PRESETS }); break; @@ -227,6 +230,36 @@ export const connectSocket = () => { }; }; +/** + * When we receive a refetch message for the rundown + * check which rundown needs to be invalidated + */ +export function maybeInvalidateRundownCache(revision: MaybeNumber) { + const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList) + ?.loaded; + + const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; + const cachedRundown = ontimeQueryClient.getQueryData(activeRundownQueryKey); + if (revision === cachedRundown?.revision) { + return; + } + + ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true }); + + if (loadedRundownId) { + // Keep bootstrap alias in sync with the ID-based cache + ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); + } else { + // During bootstrap, loadedRundownId is not yet known. + // Invalidate any ID-based rundown caches that may have been seeded early. + ontimeQueryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current', + }); + } + + ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); +} + export function sendSocket( tag: T, payload: T extends MessageTag ? Pick['payload'] : unknown, diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts index 8cc461996..7f58b2bb5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts @@ -1,8 +1,13 @@ import { useQueryClient } from '@tanstack/react-query'; -import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, ProjectRundowns, ProjectRundownsList } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; -import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants'; +import { + CURRENT_RUNDOWN_QUERY_KEY, + CUSTOM_FIELDS, + PROJECT_RUNDOWNS, + getRundownQueryKey, +} from '../../../../../common/api/constants'; import { patchData } from '../../../../../common/api/db'; import { previewRundown, @@ -81,9 +86,10 @@ export default function useGoogleSheet() { await patchData({ rundowns, customFields }); // we are unable to optimistically set the rundown since we need // it to be normalised - await queryClient.invalidateQueries({ - queryKey: [RUNDOWN, CUSTOM_FIELDS], - }); + const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; + const rundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; + await queryClient.invalidateQueries({ queryKey: rundownQueryKey }); + await queryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); } catch (error) { patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); } diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index ef3fd3fa3..be4ba6a1a 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,8 +1,8 @@ -import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types'; +import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types'; import { MouseEvent } from 'react'; import { create } from 'zustand'; -import { RUNDOWN } from '../../common/api/constants'; +import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants'; import { ontimeQueryClient } from '../../common/queryClient'; import { isMacOS } from '../../common/utils/deviceUtils'; @@ -51,7 +51,7 @@ export const useEventSelection = create()((set, get) => ({ // on ctrl + click, we toggle the selection of that event if (selectMode === 'ctrl') { - const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); + const rundownData = getLoadedRundownData(); if (!rundownData) return; // if it doesnt exist, simply add to the list and set an anchor @@ -82,7 +82,7 @@ export const useEventSelection = create()((set, get) => ({ // on shift + click, we select a range of events up to the clicked event if (selectMode === 'shift') { - const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); + const rundownData = getLoadedRundownData(); if (!rundownData) return; // get list of rundown with only ontime events @@ -136,6 +136,15 @@ 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); +} + export function getSelectionMode(event: MouseEvent): SelectionMode { if ((isMacOS() && event.metaKey) || event.ctrlKey) { return 'ctrl'; diff --git a/e2e/tests/features/214-rundown-switch-edit.spec.ts b/e2e/tests/features/214-rundown-switch-edit.spec.ts new file mode 100644 index 000000000..d0a956f37 --- /dev/null +++ b/e2e/tests/features/214-rundown-switch-edit.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; + +test('switching rundowns preserves edits per rundown', async ({ page }) => { + const suffix = Date.now(); + const nameA = `Rundown A ${suffix}`; + const nameB = `Rundown B ${suffix}`; + + await page.goto('/editor'); + + await expect(page.getByTestId('editor-container')).toBeVisible(); + + const editButton = page.getByRole('button', { name: 'Edit' }).first(); + await editButton.click(); + + // open manage rundowns and create Rundown A + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Manage Rundowns...' }).click(); + + await page.getByRole('heading', { name: 'Manage project rundowns' }).getByRole('button', { name: 'New' }).click(); + await page.getByPlaceholder('Your rundown name').fill(nameA); + await page.getByRole('button', { name: 'Create rundown' }).click(); + await expect(page.getByRole('row', { name: nameA })).toBeVisible(); + + // load Rundown A + await page.getByRole('row', { name: nameA }).getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Load', exact: true }).click(); + await page.getByRole('button', { name: 'Load rundown' }).click(); + await expect(page.getByRole('row', { name: nameA }).getByText('Loaded')).toBeVisible(); + + // close settings and add an event to Rundown A + await page.getByRole('button', { name: 'Close settings' }).click(); + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('Event in rundown A'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + // open manage rundowns and create Rundown B + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Manage Rundowns...' }).click(); + + await page.getByRole('heading', { name: 'Manage project rundowns' }).getByRole('button', { name: 'New' }).click(); + await page.getByPlaceholder('Your rundown name').fill(nameB); + await page.getByRole('button', { name: 'Create rundown' }).click(); + await expect(page.getByRole('row', { name: nameB })).toBeVisible(); + + // load Rundown B + await page.getByRole('row', { name: nameB }).getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Load', exact: true }).click(); + await page.getByRole('button', { name: 'Load rundown' }).click(); + await expect(page.getByRole('row', { name: nameB }).getByText('Loaded')).toBeVisible(); + + // close settings and add an event to Rundown B + await page.getByRole('button', { name: 'Close settings' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(0); + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('Event in rundown B'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + // switch back to Rundown A and verify its event is preserved + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Manage Rundowns...' }).click(); + await page.getByRole('row', { name: nameA }).getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Load', exact: true }).click(); + await page.getByRole('button', { name: 'Load rundown' }).click(); + await page.getByRole('button', { name: 'Close settings' }).click(); + + await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('Event in rundown A'); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + + // switch back to Rundown B and verify its event is preserved + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Manage Rundowns...' }).click(); + await page.getByRole('row', { name: nameB }).getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Load', exact: true }).click(); + await page.getByRole('button', { name: 'Load rundown' }).click(); + await page.getByRole('button', { name: 'Close settings' }).click(); + + await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('Event in rundown B'); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); +});