mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 20:09:11 +00:00
refactor(rundown): address review on the rundown scope
- extract getRundownCacheKey, the fallback to the bootstrap alias for an unresolved rundown id was spelled out in two readers and missing in the selection store, which could not resolve shift/ctrl ranges during startup - an unresolved target is no longer reported as the loaded rundown, it was briefly enabling run mode and playback following before a rundown existed - only the reader which bootstrapped drops the /current alias, a reader on another rundown was evicting a cache others still relied on - the selection store returns a new Set on toggle and unselect, components selecting the set by reference never saw the change - useSelectAndRevealEntry takes its rundown from the scope, callers were threading an id for no other reason Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
This commit is contained in:
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
|
|||||||
export const REPORT = ['report'];
|
export const REPORT = ['report'];
|
||||||
export const TRANSLATION = ['translation'];
|
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
|
// API URLs
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Rundown } from 'ontime-types';
|
import { MaybeString, Rundown } from 'ontime-types';
|
||||||
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { getRundownQueryKey } from '../api/constants';
|
import { getRundownCacheKey } from '../api/constants';
|
||||||
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
|
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
|
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
|
||||||
@@ -19,7 +19,7 @@ const RundownScopeContext = createContext<RundownScopeValue | null>(null);
|
|||||||
|
|
||||||
export interface RundownScopeProviderProps extends PropsWithChildren {
|
export interface RundownScopeProviderProps extends PropsWithChildren {
|
||||||
/** rundown to operate on, null follows the loaded rundown */
|
/** rundown to operate on, null follows the loaded rundown */
|
||||||
rundownId: string | null;
|
rundownId: MaybeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,25 +38,26 @@ export function RundownScopeProvider({ children, rundownId }: RundownScopeProvid
|
|||||||
|
|
||||||
// the store reads the rundown lazily, the ref keeps it pointing at the current target
|
// the store reads the rundown lazily, the ref keeps it pointing at the current target
|
||||||
const targetIdRef = useRef(targetId);
|
const targetIdRef = useRef(targetId);
|
||||||
targetIdRef.current = targetId;
|
|
||||||
|
|
||||||
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
|
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
|
||||||
if (selectionStoreRef.current === null) {
|
if (selectionStoreRef.current === null) {
|
||||||
selectionStoreRef.current = createEventSelectionStore(() =>
|
selectionStoreRef.current = createEventSelectionStore(() =>
|
||||||
ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(targetIdRef.current)),
|
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const selectionStore = selectionStoreRef.current;
|
const selectionStore = selectionStoreRef.current;
|
||||||
|
|
||||||
// a selection refers to entries of a single rundown, it cannot survive a change of target
|
// a selection refers to entries of a single rundown, it cannot survive a change of target
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
targetIdRef.current = targetId;
|
||||||
selectionStore.getState().clearSelectedEvents();
|
selectionStore.getState().clearSelectedEvents();
|
||||||
}, [selectionStore, targetId]);
|
}, [selectionStore, targetId]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
(): RundownScopeValue => ({
|
(): RundownScopeValue => ({
|
||||||
rundownId: targetId,
|
rundownId: targetId,
|
||||||
isLoaded: targetId === loaded,
|
// an unresolved target is not the loaded rundown, it is not yet any rundown
|
||||||
|
isLoaded: Boolean(loaded) && targetId === loaded,
|
||||||
selectionStore,
|
selectionStore,
|
||||||
}),
|
}),
|
||||||
[targetId, loaded, selectionStore],
|
[targetId, loaded, selectionStore],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
|
||||||
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
||||||
import { useRundownScope } from '../context/RundownScopeContext';
|
import { useRundownScope } from '../context/RundownScopeContext';
|
||||||
import { useSelectedEventId } from '../hooks/useSocket';
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
@@ -32,7 +32,7 @@ export function useRundownById(rundownId: string | null | undefined) {
|
|||||||
const isBootstrap = id === '';
|
const isBootstrap = id === '';
|
||||||
|
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||||
queryKey: isBootstrap ? CURRENT_RUNDOWN_QUERY_KEY : getRundownQueryKey(id),
|
queryKey: getRundownCacheKey(id),
|
||||||
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
|
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
@@ -44,9 +44,12 @@ export function useRundownById(rundownId: string | null | undefined) {
|
|||||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||||
}, [data, isBootstrap, queryClient]);
|
}, [data, isBootstrap, queryClient]);
|
||||||
|
|
||||||
// Once we have the ID, drop the temporary current cache
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isBootstrap) return;
|
if (isBootstrap || !didBootstrap.current) return;
|
||||||
|
didBootstrap.current = false;
|
||||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||||
}, [isBootstrap, queryClient]);
|
}, [isBootstrap, queryClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
||||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
import { getRundownCacheKey } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
ReorderEntry,
|
ReorderEntry,
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
@@ -77,7 +77,7 @@ export const useEntryActions = () => useEntryActionsForRundown(useRundownScope()
|
|||||||
/**
|
/**
|
||||||
* Gather utilities for actions on entries in an explicitly selected rundown.
|
* 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) {
|
function useEntryActionsForRundown(scopedRundownId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -92,9 +92,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
|
|||||||
inheritGroupColour,
|
inheritGroupColour,
|
||||||
} = useEditorSettings();
|
} = useEditorSettings();
|
||||||
|
|
||||||
// an empty id means the loaded rundown has not resolved yet, the bootstrap alias holds the data
|
|
||||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
const resolveCurrentRundownQueryKey = useCallback(() => {
|
||||||
return scopedRundownId ? getRundownQueryKey(scopedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
return getRundownCacheKey(scopedRundownId);
|
||||||
}, [scopedRundownId]);
|
}, [scopedRundownId]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ describe('createEventSelectionStore', () => {
|
|||||||
expect(store.getState().anchoredIndex).toBe(2);
|
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', () => {
|
it('does not select when the rundown is unavailable', () => {
|
||||||
const store = createEventSelectionStore(() => undefined);
|
const store = createEventSelectionStore(() => undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
type EntryCopyStore = {
|
type EntryCopyStore = {
|
||||||
entryCopyId: string | null;
|
entryCopyId: MaybeString;
|
||||||
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
|
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
|
||||||
entryCopyRundownId: string | null;
|
entryCopyRundownId: MaybeString;
|
||||||
entryCopyMode: 'copy' | 'cut';
|
entryCopyMode: 'copy' | 'cut';
|
||||||
setEntryCopyId: (eventId: string | null, rundownId: string | null, mode?: 'copy' | 'cut') => void;
|
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +16,6 @@ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
|||||||
entryCopyId: null,
|
entryCopyId: null,
|
||||||
entryCopyRundownId: null,
|
entryCopyRundownId: null,
|
||||||
entryCopyMode: 'copy',
|
entryCopyMode: 'copy',
|
||||||
setEntryCopyId: (entryCopyId: string | null, entryCopyRundownId: string | null, mode: 'copy' | 'cut' = 'copy') =>
|
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
|
||||||
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
|
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function createEventSelectionStore(getRundown: () => Rundown | undefined)
|
|||||||
// if it doesnt exist, simply add to the list and set an anchor
|
// if it doesnt exist, simply add to the list and set an anchor
|
||||||
if (!selectedEvents.has(id)) {
|
if (!selectedEvents.has(id)) {
|
||||||
return set({
|
return set({
|
||||||
selectedEvents: selectedEvents.add(id),
|
selectedEvents: new Set(selectedEvents).add(id),
|
||||||
anchoredIndex: index,
|
anchoredIndex: index,
|
||||||
cursor: id,
|
cursor: id,
|
||||||
entryMode: 'event',
|
entryMode: 'event',
|
||||||
@@ -72,15 +72,16 @@ export function createEventSelectionStore(getRundown: () => Rundown | undefined)
|
|||||||
|
|
||||||
// if event is already selected, we remove it from selection
|
// if event is already selected, we remove it from selection
|
||||||
// and set the anchor to the event after
|
// and set the anchor to the event after
|
||||||
selectedEvents.delete(id);
|
const withoutId = new Set(selectedEvents);
|
||||||
|
withoutId.delete(id);
|
||||||
|
|
||||||
const nextIndex = rundownData.order.findIndex(
|
const nextIndex = rundownData.order.findIndex(
|
||||||
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
|
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// if we didnt find anything after, set the anchor to the last event
|
// if we didnt find anything after, set the anchor to the last event
|
||||||
return set({
|
return set({
|
||||||
selectedEvents,
|
selectedEvents: withoutId,
|
||||||
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
|
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
|
||||||
entryMode: 'event',
|
entryMode: 'event',
|
||||||
});
|
});
|
||||||
@@ -125,10 +126,11 @@ export function createEventSelectionStore(getRundown: () => Rundown | undefined)
|
|||||||
},
|
},
|
||||||
unselect: (id: string) => {
|
unselect: (id: string) => {
|
||||||
const { entryMode, selectedEvents } = get();
|
const { entryMode, selectedEvents } = get();
|
||||||
selectedEvents.delete(id);
|
const remaining = new Set(selectedEvents);
|
||||||
|
remaining.delete(id);
|
||||||
set({
|
set({
|
||||||
selectedEvents,
|
selectedEvents: remaining,
|
||||||
entryMode: selectedEvents.size === 0 ? null : entryMode,
|
entryMode: remaining.size === 0 ? null : entryMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// Sets the scroll handler for programmatic scrolling to entries
|
// Sets the scroll handler for programmatic scrolling to entries
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { EntryId, MaybeString } from 'ontime-types';
|
import { EntryId, MaybeString } from 'ontime-types';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useRundownScope } from '../../common/context/RundownScopeContext';
|
||||||
import { useCollapsedGroups } from './useCollapsedGroups';
|
import { useCollapsedGroups } from './useCollapsedGroups';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
|
|||||||
parent?: MaybeString;
|
parent?: MaybeString;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSelectAndRevealEntry(rundownId: string) {
|
export function useSelectAndRevealEntry() {
|
||||||
|
const { rundownId } = useRundownScope();
|
||||||
const { expandGroup } = useCollapsedGroups(rundownId);
|
const { expandGroup } = useCollapsedGroups(rundownId);
|
||||||
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ export function searchByText(
|
|||||||
* @param activeFilter - a field selected from the filter badges, if any
|
* @param activeFilter - a field selected from the filter badges, if any
|
||||||
*/
|
*/
|
||||||
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
|
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
|
||||||
const { data, rundownId } = useFlatRundown();
|
const { data } = useFlatRundown();
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
|
|
||||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
const selectAndRevealEntry = useSelectAndRevealEntry();
|
||||||
|
|
||||||
/** The filters offered to the user: the fixed fields plus whatever the project defines */
|
/** The filters offered to the user: the fixed fields plus whatever the project defines */
|
||||||
const filters = useMemo<FinderFilter[]>(() => {
|
const filters = useMemo<FinderFilter[]>(() => {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default function TitleList({ mode }: TitleListProps) {
|
|||||||
eventData={eventData}
|
eventData={eventData}
|
||||||
selectedEventId={selectedEventId}
|
selectedEventId={selectedEventId}
|
||||||
resolvedFollowEventId={resolvedFollowEventId}
|
resolvedFollowEventId={resolvedFollowEventId}
|
||||||
rundownId={rundown.id}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,21 +55,14 @@ interface TitleListContentProps {
|
|||||||
eventData: ExtendedEntry<OntimeEvent>[];
|
eventData: ExtendedEntry<OntimeEvent>[];
|
||||||
selectedEventId: string | null;
|
selectedEventId: string | null;
|
||||||
resolvedFollowEventId: string | null;
|
resolvedFollowEventId: string | null;
|
||||||
rundownId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TitleListContent({
|
function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
|
||||||
mode,
|
|
||||||
eventData,
|
|
||||||
selectedEventId,
|
|
||||||
resolvedFollowEventId,
|
|
||||||
rundownId,
|
|
||||||
}: TitleListContentProps) {
|
|
||||||
'use memo';
|
'use memo';
|
||||||
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
const selectAndRevealEntry = useSelectAndRevealEntry();
|
||||||
|
|
||||||
// Calculate current event info
|
// Calculate current event info
|
||||||
const currentEventInfo = useMemo(() => {
|
const currentEventInfo = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user