From 63cfc9ec1933cf1a348b501a6d2ac4a8ad924c6c Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 13 Feb 2026 20:00:10 +0100 Subject: [PATCH] refactor: optimistic add elements in UI --- .../client/src/common/hooks/useEntryAction.ts | 125 ++++++++++++++++-- e2e/tests/features/204-editor-crud.spec.ts | 2 +- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 16c6efefc..a4600aedb 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -6,15 +6,31 @@ import { isOntimeEvent, isOntimeGroup, MaybeString, + OntimeDelay, OntimeEntry, OntimeEvent, + OntimeGroup, + OntimeMilestone, + PatchWithId, Rundown, SupportedEntry, TimeField, TimeStrategy, - TransientEventPayload, } from 'ontime-types'; -import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils'; +import { + addToRundown, + createDelay, + createEvent, + createGroup, + createMilestone, + dayInMs, + generateId, + getInsertAfterId, + MILLIS_PER_SECOND, + parseUserTime, + resolveInsertParent, + swapEventData, +} from 'ontime-utils'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { RUNDOWN } from '../api/constants'; @@ -85,9 +101,64 @@ export const useEntryActions = () => { * @private */ const { mutateAsync: addEntryMutation } = useMutation({ - mutationFn: ([rundownId, entry]: Parameters) => postAddEntry(rundownId, entry), - onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry), + onMutate: async ([_rundownId, entry]) => { + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + const previousData = queryClient.getQueryData(RUNDOWN); + + if (previousData) { + const optimisticEntry = createOptimisticEntry(entry); + const parentId = resolveInsertParent(previousData, entry); + const maybeParent = parentId ? previousData.entries[parentId] : null; + const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null; + const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before); + + // create a mutable copy — addToRundown mutates in place using immutable array operations + const newRundown: Rundown = { + ...previousData, + entries: { ...previousData.entries }, + revision: -1, + }; + + // copy the parent group so we don't mutate the original + if (parent && parentId) { + newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] }; + } + + addToRundown( + newRundown, + optimisticEntry, + afterId, + parent ? (newRundown.entries[parent.id] as OntimeGroup) : null, + ); + + queryClient.setQueryData(RUNDOWN, newRundown); + } + + return { previousData }; + }, + onSuccess: (response) => { + if (!response.data) return; + + const serverEntry = response.data; + const currentData = queryClient.getQueryData(RUNDOWN); + + if (currentData) { + queryClient.setQueryData(RUNDOWN, { + ...currentData, + entries: { ...currentData.entries, [serverEntry.id]: serverEntry }, + }); + } + }, + onError: (_error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(RUNDOWN, context.previousData); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, }); /** @@ -102,7 +173,15 @@ export const useEntryActions = () => { throw new Error('Rundown not initialised'); } - const newEntry: TransientEventPayload = { ...entry, id: generateId() }; + const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() }; + + // handle adding options that concern all event types + if (options?.after) { + newEntry.after = options.after; + } + if (options?.before) { + newEntry.before = options.before; + } // ************* CHECK OPTIONS specific to events if (isOntimeEvent(newEntry)) { @@ -142,14 +221,6 @@ export const useEntryActions = () => { } } - // 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([rundownId, newEntry]); } catch (error) { @@ -905,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) { return { entries, order, flatOrder }; } + +/** + * Utility to create an optimistic entry for immediate cache insertion + */ +function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry { + const { after: _after, before: _before, ...entryData } = payload; + const id = entryData.id; + let parent: EntryId | null = null; + if ('parent' in entryData && entryData.parent) { + parent = entryData.parent; + } + + switch (entryData.type) { + case SupportedEntry.Event: { + return createEvent({ ...entryData, id, parent }) as OntimeEvent; + } + case SupportedEntry.Delay: + return createDelay({ id, duration: (entryData as Partial).duration ?? 0, parent }); + case SupportedEntry.Group: + return createGroup({ ...(entryData as Partial), id }); + case SupportedEntry.Milestone: + return createMilestone({ ...(entryData as Partial), id, parent }); + default: + throw new Error('Unknown entry type'); + } +} diff --git a/e2e/tests/features/204-editor-crud.spec.ts b/e2e/tests/features/204-editor-crud.spec.ts index f3b6306ca..821833a8c 100644 --- a/e2e/tests/features/204-editor-crud.spec.ts +++ b/e2e/tests/features/204-editor-crud.spec.ts @@ -28,7 +28,7 @@ test('CRUD operations on the rundown', async ({ page }) => { // test quick add options - star2+5-t is last end await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m'); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); - expect(await page.getByTestId('entry-3').getByTestId('time-input-timeStart').inputValue()).toContain('00:30:00'); + await expect(page.getByTestId('entry-3').getByTestId('time-input-timeStart')).toHaveValue('00:30:00'); await expect(page.getByTestId('rundown-event')).toHaveCount(3); await expect(page.getByTestId('rundown-delay')).toHaveCount(1); await expect(page.getByTestId('rundown-group')).toHaveCount(1);