From 2a992a85953aa197c93ee50e3ac127a0c106a369 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 13 Sep 2026 20:20:02 +0200 Subject: [PATCH] refactor(rundown): add scoped rundown data primitives Centralise rundown identity, queries, entry actions, and selection state so a surface can safely target either the loaded or a background rundown. --- apps/client/src/common/api/constants.ts | 8 + .../context/EditableRundownScopeProvider.tsx | 24 +++ .../common/context/RundownScopeContext.tsx | 77 +++++++++ .../common/hooks-query/useLoadedRundown.ts | 81 +++++++++ .../src/common/hooks-query/useRundown.ts | 131 ++++----------- .../src/common/hooks-query/useRundownById.ts | 65 +++++++ .../common/hooks-query/useScopedRundown.ts | 54 ------ .../client/src/common/hooks/useEntryAction.ts | 65 +++++-- .../__tests__/eventSelectionStore.test.ts | 64 +++++++ .../src/common/stores/eventSelectionStore.ts | 158 ++++++++++++++++++ 10 files changed, 555 insertions(+), 172 deletions(-) create mode 100644 apps/client/src/common/context/EditableRundownScopeProvider.tsx create mode 100644 apps/client/src/common/context/RundownScopeContext.tsx create mode 100644 apps/client/src/common/hooks-query/useLoadedRundown.ts create mode 100644 apps/client/src/common/hooks-query/useRundownById.ts delete mode 100644 apps/client/src/common/hooks-query/useScopedRundown.ts create mode 100644 apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts create mode 100644 apps/client/src/common/stores/eventSelectionStore.ts diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index aa0551931..878408121 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList']; export const REPORT = ['report']; export const TRANSLATION = ['translation']; +/** + * Cache key holding the data for a rundown. + * Before the loaded rundown is known there is no id to key by, + * and the data lives under the bootstrap alias. + */ +export const getRundownCacheKey = (rundownId: string) => + rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY; + // API URLs export const apiEntryUrl = `${serverURL}/data`; diff --git a/apps/client/src/common/context/EditableRundownScopeProvider.tsx b/apps/client/src/common/context/EditableRundownScopeProvider.tsx new file mode 100644 index 000000000..bf376d51e --- /dev/null +++ b/apps/client/src/common/context/EditableRundownScopeProvider.tsx @@ -0,0 +1,24 @@ +import { PropsWithChildren } from 'react'; + +import { useScopedEntryActions } from '../hooks/useEntryAction'; +import { EntryActionsProvider } from './EntryActionsContext'; +import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext'; + +/** + * Rundown scope for subtrees which mutate entries. + * Actions are bound to the same rundown as the data, so the two cannot disagree. + */ +export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) { + return ( + + {children} + + ); +} + +function ScopedEntryActions({ children }: PropsWithChildren) { + const { rundownId } = useRundownScope(); + const actions = useScopedEntryActions(rundownId); + + return {children}; +} diff --git a/apps/client/src/common/context/RundownScopeContext.tsx b/apps/client/src/common/context/RundownScopeContext.tsx new file mode 100644 index 000000000..1578b21b0 --- /dev/null +++ b/apps/client/src/common/context/RundownScopeContext.tsx @@ -0,0 +1,77 @@ +import { MaybeString, Rundown } from 'ontime-types'; +import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react'; + +import { getRundownCacheKey } from '../api/constants'; +import { useProjectRundowns } from '../hooks-query/useProjectRundowns'; +import { ontimeQueryClient } from '../queryClient'; +import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore'; + +export type RundownScopeValue = { + /** the rundown this subtree operates on */ + rundownId: string; + /** whether this scope targets the rundown the runtime is playing */ + isLoaded: boolean; + /** selection and cursor, scoped to this rundown */ + selectionStore: EventSelectionStoreApi; +}; + +const RundownScopeContext = createContext(null); + +export interface RundownScopeProviderProps extends PropsWithChildren { + /** rundown to operate on, null follows the loaded rundown */ + rundownId: MaybeString; +} + +/** + * Declares which rundown a subtree reads from. + * + * Data hooks resolve their rundown from here, so components never need to know + * which rundown they operate on. Nest a provider to point part of the tree at a + * different rundown; the app mounts one at the root that follows the loaded rundown. + */ +export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) { + const { + data: { loaded }, + } = useProjectRundowns(); + + const targetId = rundownId ?? loaded; + + // the store reads the rundown lazily, the ref keeps it pointing at the current target + const targetIdRef = useRef(targetId); + + const selectionStoreRef = useRef(null); + if (selectionStoreRef.current === null) { + selectionStoreRef.current = createEventSelectionStore(() => + ontimeQueryClient.getQueryData(getRundownCacheKey(targetIdRef.current)), + ); + } + const selectionStore = selectionStoreRef.current; + + // a selection refers to entries of a single rundown, it cannot survive a change of target + useEffect(() => { + targetIdRef.current = targetId; + selectionStore.getState().clearSelectedEvents(); + }, [selectionStore, targetId]); + + const value = useMemo( + (): RundownScopeValue => ({ + rundownId: targetId, + // an unresolved target is not the loaded rundown, it is not yet any rundown + isLoaded: Boolean(loaded) && targetId === loaded, + selectionStore, + }), + [targetId, loaded, selectionStore], + ); + + return {children}; +} + +export function useRundownScope(): RundownScopeValue { + const context = useContext(RundownScopeContext); + + if (!context) { + throw new Error('useRundownScope must be used within a RundownScopeProvider'); + } + + return context; +} diff --git a/apps/client/src/common/hooks-query/useLoadedRundown.ts b/apps/client/src/common/hooks-query/useLoadedRundown.ts new file mode 100644 index 000000000..80f658e5d --- /dev/null +++ b/apps/client/src/common/hooks-query/useLoadedRundown.ts @@ -0,0 +1,81 @@ +import { EntryId, OntimeEntry } from 'ontime-types'; +import { useMemo } from 'react'; + +import { useSelectedEventId } from '../hooks/useSocket'; +import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; +import { useProjectRundowns } from './useProjectRundowns'; +import { flattenRundown, useRundownById } from './useRundownById'; + +/** + * Rundown data for surfaces which only ever work against the rundown being played: + * the viewers, the operator, app settings and the runtime overview. + * + * These take no part in a rundown scope, they resolve the loaded rundown directly. + * Anything which can be pointed at a background rundown reads from its scope instead. + */ +export function useLoadedRundown() { + const { + data: { loaded }, + } = useProjectRundowns(); + return useRundownById(loaded); +} + +export function useLoadedRundownWithMetadata() { + const { data, status } = useLoadedRundown(); + const selectedEventId = useSelectedEventId(); + const { entries, flatOrder } = data; + const rundownMetadata = useMemo( + () => getRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); + return { data, status, rundownMetadata }; +} + +export function useLoadedFlatRundown() { + const { data, status } = useLoadedRundown(); + const flatRundown = useMemo(() => flattenRundown(data), [data]); + return { data: flatRundown, rundownId: data.id, status }; +} + +export function useLoadedFlatRundownWithMetadata() { + const { data, status } = useLoadedRundown(); + const selectedEventId = useSelectedEventId(); + const { entries, flatOrder } = data; + const rundownWithMetadata = useMemo( + () => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); + return { data: rundownWithMetadata, status }; +} + +/** + * Provides access to a partial rundown based on a filter callback + * + * Callers MUST memoize the callback with useCallback to prevent + * re-filtering on every render. + */ +export function useLoadedPartialRundown(cb: (event: ExtendedEntry) => boolean) { + const { data, status } = useLoadedFlatRundownWithMetadata(); + const filteredData = useMemo(() => data.filter(cb), [data, cb]); + return { data: filteredData, status }; +} + +/** + * Hook to get a specific entry by ID from the loaded rundown. + * Runtime ids (the playing event, its group, the next flag) only exist here. + */ +export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null { + const { data: rundown } = useLoadedRundown(); + + if (entryId === null) return null; + return rundown.entries[entryId] ?? null; +} + +export function useLoadedRundownAuxData() { + const { data, status } = useLoadedRundown(); + const filteredData = useMemo(() => { + const { title, id } = data; + return { title, id }; + }, [data]); + return { data: filteredData, status }; +} diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 4912f688b..cef300a65 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,62 +1,38 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; -import { useEffect, useMemo } from 'react'; +import { EntryId, OntimeEntry } 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 { useRundownScope } from '../context/RundownScopeContext'; 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 = { - id: 'default', - title: '', - order: [], - flatOrder: [], - entries: {}, - revision: -1, -}; +import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; +import { flattenRundown, useRundownById } from './useRundownById'; /** - * 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`. + * Normalised rundown data for the rundown of the enclosing scope */ export default function useRundown() { - const queryClient = useQueryClient(); - const { - data: { loaded: loadedRundownId }, - } = useProjectRundowns(); + const { rundownId } = useRundownScope(); + return useRundownById(rundownId); +} - const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, - queryFn: ({ signal }) => fetchCurrentRundown({ signal }), - 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 }; +/** + * Runtime state only describes the loaded rundown, + * a scope pointed elsewhere must not show a playing event + */ +export function useScopedSelectedEventId(): EntryId | null { + const { isLoaded } = useRundownScope(); + const selectedEventId = useSelectedEventId(); + return isLoaded ? selectedEventId : null; } export function useRundownWithMetadata() { const { data, status } = useRundown(); - const selectedEventId = useSelectedEventId(); - const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const selectedEventId = useScopedSelectedEventId(); + // key on the fields the derivation reads, a revision only change must not churn the list + const { entries, flatOrder } = data; + const rundownMetadata = useMemo( + () => getRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); return { data, status, rundownMetadata }; } @@ -66,41 +42,23 @@ export function useRundownWithMetadata() { */ export function useFlatRundown() { const { data, status } = useRundown(); - - const flatRundown = useMemo(() => { - if (data.revision === -1) { - return []; - } - return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined); - }, [data]); + const flatRundown = useMemo(() => flattenRundown(data), [data]); return { data: flatRundown, rundownId: data.id, status }; } export function useFlatRundownWithMetadata() { const { data, status } = useRundown(); - const selectedEventId = useSelectedEventId(); + const selectedEventId = useScopedSelectedEventId(); - const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const { entries, flatOrder } = data; + const rundownWithMetadata = useMemo( + () => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); return { data: rundownWithMetadata, status }; } -/** - * Provides access to a partial rundown based on a filter callback - * - * Callers MUST memoize the callback with useCallback to prevent - * re-filtering on every render. - * - */ -export function usePartialRundown(cb: (event: ExtendedEntry) => boolean) { - const { data, status } = useFlatRundownWithMetadata(); - const filteredData = useMemo(() => { - return data.filter(cb); - }, [data, cb]); - - return { data: filteredData, status }; -} - /** * Hook to get a specific entry by ID from the rundown */ @@ -110,30 +68,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null { if (entryId === null) return null; return rundown.entries[entryId] ?? null; } - -export function useRundownAuxData() { - const { data, status } = useRundown(); - 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/useRundownById.ts b/apps/client/src/common/hooks-query/useRundownById.ts new file mode 100644 index 000000000..6725b0090 --- /dev/null +++ b/apps/client/src/common/hooks-query/useRundownById.ts @@ -0,0 +1,65 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { OntimeEntry, Rundown } from 'ontime-types'; +import { useEffect, useRef } from 'react'; + +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants'; +import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; + +// revision is -1 so that the remote revision is higher +const cachedRundownPlaceholder: Rundown = { + id: 'default', + title: '', + order: [], + flatOrder: [], + entries: {}, + revision: -1, +}; + +/** + * Provides access to a specific rundown by ID. + * + * Without an ID we do not yet know which rundown is loaded, so we bootstrap via + * the `/current` alias to keep the first paint to a single round-trip, then seed + * the id-keyed cache that every other reader shares. + */ +export function useRundownById(rundownId: string | null | undefined) { + const queryClient = useQueryClient(); + const id = rundownId ?? ''; + const isBootstrap = id === ''; + + const { data, status, isError, refetch, isFetching } = useQuery({ + queryKey: getRundownCacheKey(id), + queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })), + refetchInterval: queryRefetchIntervalSlow, + }); + + // Seed the id-keyed cache when fetching via the bootstrap alias + useEffect(() => { + if (!data || !isBootstrap) return; + queryClient.setQueryData(getRundownQueryKey(data.id), data); + }, [data, isBootstrap, queryClient]); + + // Once we have the ID, drop the temporary current cache. + // Only the reader which bootstrapped may do so, others are still relying on it. + const didBootstrap = useRef(isBootstrap); + useEffect(() => { + if (isBootstrap || !didBootstrap.current) return; + didBootstrap.current = false; + queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); + }, [isBootstrap, queryClient]); + + return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; +} + +/** + * Builds a flat rundown from the order and entries fields + */ +export function flattenRundown(rundown: Rundown): OntimeEntry[] { + if (rundown.revision === -1) { + return []; + } + return rundown.flatOrder + .map((id) => rundown.entries[id]) + .filter((entry): entry is OntimeEntry => entry !== undefined); +} 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 a14707d7f..000000000 --- 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/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index a561e416e..7f785a172 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -8,7 +8,6 @@ import { OntimeGroup, OntimeMilestone, PatchWithId, - ProjectRundownsList, Rundown, SupportedEntry, TimeField, @@ -32,9 +31,10 @@ import { swapEventData, } from 'ontime-utils'; import { useCallback, useMemo } from 'react'; +import isEqual from 'react-fast-compare'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; -import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants'; +import { getRundownCacheKey } from '../api/constants'; import { ReorderEntry, deleteEntries, @@ -52,6 +52,7 @@ import { requestFitGroupTarget, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; +import { useRundownScope } from '../context/RundownScopeContext'; import { useEditorSettings } from '../stores/editorSettings'; export type EventOptions = Partial<{ @@ -64,22 +65,38 @@ export type EventOptions = Partial<{ lastEventId: MaybeString; }>; +/** + * Applies a patch the way the server does, revision included. + * + * An entry revision advances on every change to that entry, which makes it a + * cheap marker for whether an entry has moved on. The optimistic entry has to + * carry the revision the server will return, otherwise the two disagree and the + * refetch resolves to a different object for no reason. + * Mirrors applyPatchToEntry in the rundown service: delays carry no revision. + */ +function patchEntry(entry: OntimeEntry, patch: Partial): OntimeEntry { + if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) { + return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry; + } + return { ...entry, ...patch } as OntimeEntry; +} + type ClientInsertOptions = { after?: EntryId; before?: EntryId; }; /** - * Gather utilities for actions on entries in the loaded rundown. + * Gather utilities for actions on entries in the rundown of the enclosing scope. */ -export const useEntryActions = () => useEntryActionsForRundown(undefined); +export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId); /** * Gather utilities for actions on entries in an explicitly selected rundown. */ -export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? ''); +export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? ''); -function useEntryActionsForRundown(scopedRundownId: string | undefined) { +function useEntryActionsForRundown(scopedRundownId: string) { const queryClient = useQueryClient(); const { linkPrevious, @@ -93,12 +110,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { } = useEditorSettings(); const resolveCurrentRundownQueryKey = useCallback(() => { - if (scopedRundownId !== undefined) { - return getRundownQueryKey(scopedRundownId); - } - const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; - return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; - }, [queryClient, scopedRundownId]); + return getRundownCacheKey(scopedRundownId); + }, [scopedRundownId]); /** * Returns the currently loaded rundown @@ -325,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { if (previousData && eventId) { // optimistically update object const newRundown = { ...previousData.entries }; - // @ts-expect-error -- we expect the events to be of same type - newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; + const previousEntry = newRundown[eventId]; + if (previousEntry) { + newRundown[eventId] = patchEntry(previousEntry, newEvent); + } queryClient.setQueryData(queryKey, { id: previousData.id, title: previousData.title, @@ -340,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { // Return a context with the previous and new events return { previousData, newEvent, queryKey }; }, + // the server is the authority on the applied patch, it may normalise what we sent + onSuccess: (response, _variables, context) => { + const serverEntry = response.data; + if (!serverEntry || !context?.queryKey) return; + + const cachedRundown = queryClient.getQueryData(context.queryKey); + if (!cachedRundown) return; + + // our optimistic entry usually describes the change exactly, writing an + // identical entry would discard the cached reference for nothing + if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return; + + queryClient.setQueryData(context.queryKey, { + ...cachedRundown, + entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry }, + }); + }, // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { if (context?.previousData && context?.queryKey) { @@ -512,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { if (Object.hasOwn(newRundown, eventId)) { const event = newRundown[eventId]; if (isOntimeEvent(event)) { - newRundown[eventId] = { - ...event, - ...data, - }; + newRundown[eventId] = patchEntry(event, data.data); } } }); diff --git a/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts b/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts new file mode 100644 index 000000000..1aa309fee --- /dev/null +++ b/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts @@ -0,0 +1,64 @@ +import { Rundown, SupportedEntry } from 'ontime-types'; + +import { createEventSelectionStore } from '../eventSelectionStore'; + +function makeRundown(id: string, eventIds: string[]): Rundown { + return { + id, + title: id, + order: eventIds, + flatOrder: eventIds, + entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])), + revision: 1, + } as unknown as Rundown; +} + +describe('createEventSelectionStore', () => { + it('keeps selections of separate instances independent', () => { + const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2'])); + const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2'])); + + first.getState().setSingleEntrySelection({ id: 'a1' }); + second.getState().setSingleEntrySelection({ id: 'b2' }); + + expect(first.getState().cursor).toBe('a1'); + expect(second.getState().cursor).toBe('b2'); + expect(first.getState().selectedEvents).toEqual(new Set(['a1'])); + expect(second.getState().selectedEvents).toEqual(new Set(['b2'])); + }); + + it('resolves a shift range against the injected rundown', () => { + const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3'])); + + store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' }); + + // without an anchor the range runs from the top up to the clicked index + expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2'])); + expect(store.getState().anchoredIndex).toBe(2); + }); + + it('replaces the selection set so subscribers on the set re-render', () => { + const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2'])); + + store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' }); + const afterClick = store.getState().selectedEvents; + + store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' }); + const afterAdd = store.getState().selectedEvents; + expect(afterAdd).not.toBe(afterClick); + expect(afterAdd).toEqual(new Set(['a1', 'a2'])); + + store.getState().unselect('a1'); + const afterUnselect = store.getState().selectedEvents; + expect(afterUnselect).not.toBe(afterAdd); + expect(afterUnselect).toEqual(new Set(['a2'])); + }); + + it('does not select when the rundown is unavailable', () => { + const store = createEventSelectionStore(() => undefined); + + store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' }); + + expect(store.getState().selectedEvents).toEqual(new Set()); + }); +}); diff --git a/apps/client/src/common/stores/eventSelectionStore.ts b/apps/client/src/common/stores/eventSelectionStore.ts new file mode 100644 index 000000000..0db39c67e --- /dev/null +++ b/apps/client/src/common/stores/eventSelectionStore.ts @@ -0,0 +1,158 @@ +import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types'; +import { MouseEvent } from 'react'; +import { StoreApi } from 'zustand'; +import { createStore } from 'zustand/vanilla'; + +import { isMacOS } from '../utils/deviceUtils'; + +export type SelectionMode = 'shift' | 'click' | 'ctrl'; + +export interface EventSelectionStore { + selectedEvents: Set; + anchoredIndex: MaybeNumber; + cursor: EntryId | null; + entryMode: 'event' | 'single' | null; + scrollHandler: ((id: EntryId) => void) | null; + setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; + setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; + clearSelectedEvents: () => void; + clearMultiSelect: () => void; + unselect: (id: EntryId) => void; + setScrollHandler: (handler: ((id: EntryId) => void) | null) => void; + scrollToEntry: (id: EntryId) => void; +} + +export type EventSelectionStoreApi = StoreApi; + +/** + * Keeps track of the selected entries and selection mode + * Provides methods to update the selection based on user interactions + * + * One store instance exists per rundown scope, so panels showing different + * rundowns keep independent selections. The rundown the selection refers to is + * injected as `getRundown` rather than resolved from the loaded rundown. + */ +export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi { + return createStore()((set, get) => ({ + selectedEvents: new Set(), + anchoredIndex: null, + cursor: null, + entryMode: null, + scrollHandler: null, + setSingleEntrySelection: ({ id }) => { + set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); + }, + setSelectedEvents: ({ id, index, selectMode }) => { + const { selectedEvents, anchoredIndex, entryMode } = get(); + + // if we are in single mode, we replace the selection and change the mode + if (entryMode === 'single') { + return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' }); + } + + // on click, we replace selection with event + if (selectMode === 'click') { + return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' }); + } + + // on ctrl + click, we toggle the selection of that event + if (selectMode === 'ctrl') { + const rundownData = getRundown(); + if (!rundownData) return; + + // if it doesnt exist, simply add to the list and set an anchor + if (!selectedEvents.has(id)) { + return set({ + selectedEvents: new Set(selectedEvents).add(id), + anchoredIndex: index, + cursor: id, + entryMode: 'event', + }); + } + + // if event is already selected, we remove it from selection + // and set the anchor to the event after + const withoutId = new Set(selectedEvents); + withoutId.delete(id); + + const nextIndex = rundownData.order.findIndex( + (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId), + ); + + // if we didnt find anything after, set the anchor to the last event + return set({ + selectedEvents: withoutId, + anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex, + entryMode: 'event', + }); + } + + // on shift + click, we select a range of events up to the clicked event + if (selectMode === 'shift') { + const rundownData = getRundown(); + if (!rundownData) return; + + // get list of rundown with only ontime events + const eventIds: EntryId[] = []; + rundownData.flatOrder.forEach((eventId) => { + const event = rundownData.entries[eventId]; + if (isOntimeEvent(event)) { + eventIds.push(event.id); + } + }); + + const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index); + const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1); + + // create new set with range of ids from start to end + const selectedEventIds = eventIds.slice(start, end); + + return set({ + selectedEvents: new Set([...selectedEvents, ...selectedEventIds]), + anchoredIndex: index, + entryMode: 'event', + }); + } + }, + clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }), + clearMultiSelect: () => { + const { selectedEvents } = get(); + const [firstSelected] = selectedEvents; + set({ + selectedEvents: new Set(firstSelected ? [firstSelected] : []), + anchoredIndex: null, + entryMode: null, + }); + }, + unselect: (id: string) => { + const { entryMode, selectedEvents } = get(); + const remaining = new Set(selectedEvents); + remaining.delete(id); + set({ + selectedEvents: remaining, + entryMode: remaining.size === 0 ? null : entryMode, + }); + }, + // Sets the scroll handler for programmatic scrolling to entries + setScrollHandler: (handler) => set({ scrollHandler: handler }), + // Scrolls to the specified entry using the registered scroll handler + scrollToEntry: (id: EntryId) => { + const handler = get().scrollHandler; + if (handler) { + handler(id); + } + }, + })); +} + +export function getSelectionMode(event: MouseEvent): SelectionMode { + if ((isMacOS() && event.metaKey) || event.ctrlKey) { + return 'ctrl'; + } + + if (event.shiftKey) { + return 'shift'; + } + + return 'click'; +}