refactor: optimistic add elements in UI

This commit is contained in:
Carlos Valente
2026-02-13 20:00:10 +01:00
committed by Carlos Valente
parent 4827117163
commit 63cfc9ec19
2 changed files with 112 additions and 15 deletions
+111 -14
View File
@@ -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<typeof postAddEntry>) => 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>(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>(RUNDOWN, newRundown);
}
return { previousData };
},
onSuccess: (response) => {
if (!response.data) return;
const serverEntry = response.data;
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (currentData) {
queryClient.setQueryData<Rundown>(RUNDOWN, {
...currentData,
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
});
}
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(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<OntimeDelay>).duration ?? 0, parent });
case SupportedEntry.Group:
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
case SupportedEntry.Milestone:
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
default:
throw new Error('Unknown entry type');
}
}
+1 -1
View File
@@ -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);