import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { EntryId, isOntimeBlock, isOntimeEvent, MaybeString, OntimeBlock, OntimeEntry, OntimeEvent, Rundown, TimeField, TimeStrategy, TransientEventPayload, } from 'ontime-types'; import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils'; import { moveDown, moveUp } from '../../features/rundown/rundown.utils'; import { RUNDOWN } from '../api/constants'; import { deleteEntries, patchReorderEntry, postAddEntry, postCloneEntry, putBatchEditEvents, putEditEntry, ReorderEntry, requestApplyDelay, requestDeleteAll, requestEventSwap, requestGroupEntries, requestUngroup, SwapEntry, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; import { useEditorSettings } from '../stores/editorSettings'; export type EventOptions = Partial<{ // options to any new block (event / delay / block) after: MaybeString; before: MaybeString; // options to blocks of type OntimeEvent defaultPublic: boolean; linkPrevious: boolean; lastEventId: MaybeString; }>; /** * Gather utilities for actions on entries */ export const useEntryActions = () => { const queryClient = useQueryClient(); const { defaultPublic, linkPrevious, defaultTimeStrategy, defaultDuration, defaultWarnTime, defaultDangerTime, defaultTimerType, defaultEndAction, } = useEditorSettings(); const getEntryById = useCallback( (eventId: string): OntimeEntry | undefined => { const cachedRundown = queryClient.getQueryData(RUNDOWN); if (!cachedRundown?.entries) { return; } return cachedRundown.entries[eventId]; }, [queryClient], ); /** * Calls mutation to add new entry * @private */ const _addEntryMutation = useMutation({ // TODO(v4): optimistic create entry mutationFn: postAddEntry, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); /** * Adds an entry to rundown */ const addEntry = useCallback( async (entry: Partial, options?: EventOptions) => { const newEntry: TransientEventPayload = { ...entry, id: generateId() }; // ************* CHECK OPTIONS specific to events if (isOntimeEvent(newEntry)) { // merge creation time options with event settings const applicationOptions = { after: options?.after, before: options?.before, defaultPublic: options?.defaultPublic ?? defaultPublic, lastEventId: options?.lastEventId, linkPrevious: options?.linkPrevious ?? linkPrevious, }; if (applicationOptions?.lastEventId) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value const rundownData = queryClient.getQueryData(RUNDOWN)!; const previousEvent = rundownData.entries[applicationOptions.lastEventId]; if (isOntimeEvent(previousEvent)) { newEntry.timeStart = previousEvent.timeEnd; } } // Override event with options from editor settings newEntry.linkStart = applicationOptions.linkPrevious; newEntry.isPublic = applicationOptions.defaultPublic; if (newEntry.duration === undefined && newEntry.timeEnd === undefined) { newEntry.duration = parseUserTime(defaultDuration); } if (newEntry.timeDanger === undefined) { newEntry.timeDanger = parseUserTime(defaultDangerTime); } if (newEntry.timeWarning === undefined) { newEntry.timeWarning = parseUserTime(defaultWarnTime); } if (newEntry.timerType === undefined) { newEntry.timerType = defaultTimerType; } if (newEntry.endAction === undefined) { newEntry.endAction = defaultEndAction; } if (newEntry.timeStrategy === undefined) { newEntry.timeStrategy = defaultTimeStrategy; } } // handle adding options that concern all event type if (options?.after) { (newEntry as TransientEventPayload).after = options.after; } if (options?.before) { (newEntry as TransientEventPayload).before = options.before; } try { await _addEntryMutation.mutateAsync(newEntry); } catch (error) { logAxiosError('Failed adding event', error); } }, [ _addEntryMutation, defaultDangerTime, defaultDuration, defaultEndAction, defaultPublic, defaultTimerType, defaultTimeStrategy, defaultWarnTime, linkPrevious, queryClient, ], ); /** * Calls mutation to clone a selection * @private */ const _cloneMutation = useMutation({ mutationFn: postCloneEntry, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); /** * Clone a selection */ const clone = useCallback( async (entryId: EntryId) => { try { await _cloneMutation.mutateAsync(entryId); } catch (error) { logAxiosError('Error cloning entry', error); } }, [_cloneMutation], ); /** * Calls mutation to update existing entry * @private */ const _updateEntryMutation = useMutation({ mutationFn: putEditEntry, // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value const previousData = queryClient.getQueryData(RUNDOWN); const eventId = newEvent.id; 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 }; queryClient.setQueryData(RUNDOWN, { id: previousData.id, title: previousData.title, order: previousData.order, flatOrder: previousData.flatOrder, entries: newRundown, revision: -1, }); } // Return a context with the previous and new events return { previousData, newEvent }; }, // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: async () => { await queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, }); /** * Updates existing entry */ const updateEntry = useCallback( async (event: Partial) => { try { await _updateEntryMutation.mutateAsync(event); } catch (error) { logAxiosError('Error updating event', error); } }, [_updateEntryMutation], ); const updateCustomField = useCallback( async (entryId: EntryId, field: string, value: string) => { updateEntry({ id: entryId, custom: { [field]: value } }); }, [updateEntry], ); /** * Updates time of existing event * @param eventId {EntryId} - id of the event * @param field {TimeField} - field to update * @param value {string} - new value string to be parsed * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update */ const updateTimer = useCallback( async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => { // an empty value with no lock has no domain validity if (!lockOnUpdate && value === '') { return; } const newEvent: Partial = { id: eventId, }; // check if we should lock the field if (lockOnUpdate) { if (field === 'timeEnd') { // an empty value indicates that we should unlock the field newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd; newEvent.timeEnd = value === '' ? undefined : calculateNewValue(); } else if (field === 'duration') { // an empty value indicates that we should unlock the field newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration; newEvent.duration = value === '' ? undefined : calculateNewValue(); } else if (field === 'timeStart') { // an empty values means we should link to the previous newEvent.linkStart = value === ''; newEvent.timeStart = value === '' ? undefined : calculateNewValue(); } } else { newEvent[field] = calculateNewValue(); } try { await _updateEntryMutation.mutateAsync(newEvent); } catch (error) { logAxiosError('Error updating event', error); } /** * Utility function to calculate the new time value */ function calculateNewValue(): number { let newValMillis = 0; // check for previous keyword if (value === 'p' || value === 'prev' || value === 'previous') { newValMillis = getPreviousEnd(); // check for adding time keyword } else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) { // TODO: is this logic solid? const remainingString = value.substring(1); newValMillis = getPreviousEnd() + parseUserTime(remainingString); } else { newValMillis = parseUserTime(value); } // dont allow timer values over 23:59:59 return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND); } /** * Utility function to get the previous event end time */ function getPreviousEnd(): number { const cachedRundown = queryClient.getQueryData(RUNDOWN); if (!cachedRundown?.order || !cachedRundown?.entries) { return 0; } const index = cachedRundown.order.indexOf(eventId); if (index === 0) { return 0; } let previousEnd = 0; for (let i = index - 1; i >= 0; i--) { const event = cachedRundown.entries[cachedRundown.order[i]]; if (isOntimeEvent(event)) { previousEnd = event.timeEnd; break; } } return previousEnd; } }, [_updateEntryMutation, queryClient], ); /** * Calls mutation to edit multiple events * @private */ const _batchUpdateEventsMutation = useMutation({ mutationFn: putBatchEditEvents, onMutate: async ({ ids, data }) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value const previousRundown = queryClient.getQueryData(RUNDOWN); if (previousRundown) { const eventIds = new Set(ids); const newRundown = { ...previousRundown.entries }; eventIds.forEach((eventId) => { if (Object.hasOwn(newRundown, eventId)) { const event = newRundown[eventId]; if (isOntimeEvent(event)) { newRundown[eventId] = { ...event, ...data, }; } } }); queryClient.setQueryData(RUNDOWN, { id: previousRundown.id, title: previousRundown.title, order: previousRundown.order, flatOrder: previousRundown.flatOrder, entries: newRundown, revision: -1, }); } // Return a context with the previous rundown return { previousRundown }; }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, onError: (_error, _newEvent, context) => { queryClient.setQueryData(RUNDOWN, context?.previousRundown); }, }); const batchUpdateEvents = useCallback( async (data: Partial, eventIds: string[]) => { try { await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data }); } catch (error) { logAxiosError('Error updating events', error); } }, [_batchUpdateEventsMutation], ); /** * Calls mutation to delete an entry * @private */ const _deleteEntryMutation = useMutation({ mutationFn: deleteEntries, // we optimistically update here onMutate: async (entryIds: EntryId[]) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData); queryClient.setQueryData(RUNDOWN, { id: previousData.id, title: previousData.title, order, flatOrder, entries, revision: -1, }); } // Return a context with the previous and new events return { previousData }; }, // Mutation fails, rollback undoes optimist update onError: (_error, _entryIds, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, }); /** * Deletes an event entry from the rundown */ const deleteEntry = useCallback( async (entryIds: EntryId[]) => { try { await _deleteEntryMutation.mutateAsync(entryIds); } catch (error) { logAxiosError('Error deleting event', error); } }, [_deleteEntryMutation], ); /** * Calls mutation to delete all events * @private */ const _deleteAllEntriesMutation = useMutation({ mutationFn: requestDeleteAll, // we optimistically update here onMutate: async () => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value const previousData = queryClient.getQueryData(RUNDOWN); // optimistically update object queryClient.setQueryData(RUNDOWN, { id: previousData?.id ?? 'default', title: previousData?.title ?? '', order: [], flatOrder: [], entries: {}, revision: -1, }); // Return a context with the previous and new events return { previousData }; }, // Mutation fails, rollback optimist update onError: (_error, _, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, }); /** * Deletes all entries in the rundown */ const deleteAllEntries = useCallback(async () => { try { await _deleteAllEntriesMutation.mutateAsync(); } catch (error) { logAxiosError('Error deleting events', error); } }, [_deleteAllEntriesMutation]); /** * Calls mutation to apply a delay * @private */ const _applyDelayMutation = useMutation({ mutationFn: requestApplyDelay, // Mutation finished, failed or successful onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, }); /** * Applies a given delay */ const applyDelay = useCallback( async (delayEventId: EntryId) => { try { await _applyDelayMutation.mutateAsync(delayEventId); } catch (error) { logAxiosError('Error applying delay', error); } }, [_applyDelayMutation], ); /** * Calls mutation to dissolve a block * @private */ const _ungroupMutation = useMutation({ mutationFn: requestUngroup, onSuccess: (response) => { if (!response.data) return; const { id, title, order, flatOrder, entries, revision } = response.data; queryClient.setQueryData(RUNDOWN, { id, title, order, flatOrder, entries, revision, }); }, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); /** * Deletes a block and moves its events to the top level */ const ungroup = useCallback( async (blockId: EntryId) => { try { await _ungroupMutation.mutateAsync(blockId); } catch (error) { logAxiosError('Error dissolving block', error); } }, [_ungroupMutation], ); /** * Calls mutation to create a block with a selection * @private */ const _groupEntriesMutation = useMutation({ mutationFn: requestGroupEntries, onSuccess: (response) => { if (!response.data) return; const { id, title, order, flatOrder, entries, revision } = response.data; queryClient.setQueryData(RUNDOWN, { id, title, order, flatOrder, entries, revision, }); }, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); /** * Create a block with a selection */ const groupEntries = useCallback( async (entryIds: EntryId[]) => { try { await _groupEntriesMutation.mutateAsync(entryIds); } catch (error) { logAxiosError('Error grouping entries', error); } }, [_groupEntriesMutation], ); /** * Calls mutation to reorder an entry * @private */ const _reorderEntryMutation = useMutation({ mutationFn: patchReorderEntry, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, }); /** * Reorders a given entry */ const reorderEntry = useCallback( async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { try { const reorderObject: ReorderEntry = { entryId, destinationId, order, }; await _reorderEntryMutation.mutateAsync(reorderObject); } catch (error) { logAxiosError('Error re-ordering event', error); } }, [_reorderEntryMutation], ); const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => { const cachedRundown = queryClient.getQueryData(RUNDOWN); if (!cachedRundown?.order) { return; } const { destinationId, order } = direction === 'up' ? moveUp(entryId, cachedRundown.order, cachedRundown.entries) : moveDown(entryId, cachedRundown.order, cachedRundown.entries); if (destinationId) { try { const reorderObject: ReorderEntry = { entryId, destinationId, order: order as 'before' | 'after' | 'insert', }; await _reorderEntryMutation.mutateAsync(reorderObject); } catch (error) { logAxiosError('Error re-ordering event', error); } } }, []); /** * Calls mutation to swap events * @private */ const _swapEvents = useMutation({ mutationFn: requestEventSwap, // we optimistically update here onMutate: async ({ from, to }) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object const newRundown = { ...previousData.entries }; const eventA = previousData.entries[from]; const eventB = previousData.entries[to]; if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { return; } const [newA, newB] = swapEventData(eventA, eventB); newRundown[from] = newA; newRundown[to] = newB; queryClient.setQueryData(RUNDOWN, { id: previousData.id, title: previousData.title, order: previousData.order, flatOrder: previousData.flatOrder, entries: newRundown, revision: -1, }); } // Return a context with the previous events return { previousData }; }, // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, }); /** * Swaps the schedule of two events */ const swapEvents = useCallback( async ({ from, to }: SwapEntry) => { try { await _swapEvents.mutateAsync({ from, to }); } catch (error) { logAxiosError('Error re-ordering event', error); } }, [_swapEvents], ); return { addEntry, applyDelay, batchUpdateEvents, clone, deleteEntry, deleteAllEntries, ungroup, getEntryById, groupEntries, move, reorderEntry, swapEvents, updateEntry, updateTimer, updateCustomField, }; }; /** * Utility to optimistically delete entries from client cache */ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) { const entries = { ...rundown.entries }; let order = [...rundown.order]; let flatOrder = [...rundown.flatOrder]; for (let i = 0; i < entryIds.length; i++) { const entry = entries[entryIds[i]]; deleteEntry(entry); } function deleteEntry(entry: OntimeEntry) { if (isOntimeBlock(entry) || !entry.parent) { order = order.filter((id) => id !== entry.id); } else { const parent = entries[entry.parent] as OntimeBlock; parent.events = parent.events.filter((event) => event !== entry.id); } delete entries[entry.id]; flatOrder = flatOrder.filter((id) => id !== entry.id); } return { entries, order, flatOrder }; }