Improve Clone (#1897)

* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2025-11-28 16:20:52 +01:00
committed by GitHub
parent a78586d2fa
commit 3f1f06f7c5
21 changed files with 321 additions and 250 deletions
+1 -1
View File
@@ -17,7 +17,6 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
+15 -4
View File
@@ -75,7 +75,10 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(rundownId: RundownId, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -107,7 +110,11 @@ export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(rundownId: RundownId, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
@@ -121,8 +128,12 @@ export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId):
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/** /**
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId), mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
await cloneEntryMutation([rundownId, entryId]); await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
+39 -26
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb'; import { TbFlagFilled } from 'react-icons/tb';
import { import {
closestCenter, closestCenter,
@@ -36,7 +36,6 @@ import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -58,6 +57,8 @@ interface RundownProps {
} }
export default function Rundown({ data, rundownMetadata }: RundownProps) { export default function Rundown({ data, rundownMetadata }: RundownProps) {
'use memo';
const { order, entries, id } = data; const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor(); const featureData = useRundownEditor();
@@ -68,17 +69,20 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
key: `rundown.${id}-editor-collapsed-groups`, key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [], defaultValue: [],
}); });
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions(); const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor // cursor
const [editorMode] = useSessionStorage<AppMode>({ const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode, key: sessionKeys.editorMode,
defaultValue: AppMode.Edit, defaultValue: AppMode.Edit,
}); });
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const cursorRef = useRef<HTMLDivElement | null>(null); const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -105,20 +109,30 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (atId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId; // lazily get the value from the store
if (copyId === null) { const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEntry.Event) { let normalisedAtId = atId;
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const elementToCopy = entries[entryCopyId];
addEntry(newEvent, { after: adjustedCursor ?? undefined }); const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
} }
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
}, },
[addEntry, order, entries], [entries, clone],
); );
/** /**
@@ -200,9 +214,9 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
*/ */
const getIsCollapsed = useCallback( const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => { (groupId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === groupId)); return collapsedGroupSet.has(groupId);
}, },
[collapsedGroups], [collapsedGroupSet],
); );
/** /**
@@ -300,12 +314,8 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
], ],
['mod + C', () => setEntryCopyId(cursor)], ['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)], ['mod + V', () => insertCopyAtId(cursor)],
[ ['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }], ['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]); ]);
@@ -395,7 +405,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
} }
// keep copy of the current state in case we need to revert // keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData); const currentEntries = [...sortableData];
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => { setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex); return reorderArray(currentEntries, fromIndex, toIndex);
@@ -438,9 +448,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />; return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
} }
// 1. gather presentation options // gather presentation options
const isEditMode = editorMode === AppMode.Edit; const isEditMode = editorMode === AppMode.Edit;
// gather rundown wide data
const lastEntryId = order.at(-1);
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext <DndContext
@@ -509,7 +522,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour; const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0; const isFirst = index === 0;
const isLast = entryId === order.at(-1); const isLast = entryId === lastEntryId;
/** /**
* We need to provide the parent ID for the QuickAdd components * We need to provide the parent ID for the QuickAdd components
@@ -1,16 +1,4 @@
import { import { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { cloneEvent } from '../../common/utils/clone';
import RundownDelay from './rundown-delay/RundownDelay'; import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent'; import RundownEvent from './rundown-event/RundownEvent';
@@ -44,13 +32,6 @@ export default function RundownEntry({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEntryProps) { }: RundownEntryProps) {
const { addEntry } = useEntryActions();
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
return ( return (
<RundownEvent <RundownEvent
@@ -83,7 +64,6 @@ export default function RundownEntry({
dayOffset={data.dayOffset} dayOffset={data.dayOffset}
totalGap={totalGap} totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0} hasTriggers={data.triggers.length > 0}
/> />
); );
@@ -56,7 +56,6 @@ interface RundownEventProps {
dayOffset: number; dayOffset: number;
totalGap: number; totalGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
createCloneEvent: () => void;
hasTriggers: boolean; hasTriggers: boolean;
} }
@@ -91,10 +90,9 @@ export default function RundownEvent({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
createCloneEvent,
}: RundownEventProps) { }: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -172,7 +170,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Clone', label: 'Clone',
icon: IoDuplicateOutline, icon: IoDuplicateOutline,
onClick: createCloneEvent, onClick: () => clone(eventId, { after: eventId }),
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
+9 -1
View File
@@ -9,6 +9,10 @@ import { ONTIME_VERSION } from './src/ONTIME_VERSION';
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
const ReactCompilerConfig = {
compilationMode: 'annotation',
};
export default defineConfig({ export default defineConfig({
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
define: { define: {
@@ -16,7 +20,11 @@ export default defineConfig({
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker', 'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
}, },
plugins: [ plugins: [
react(), react({
babel: {
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
},
}),
svgrPlugin(), svgrPlugin(),
sentryAuthToken && sentryAuthToken &&
sentryVitePlugin({ sentryVitePlugin({
+1
View File
@@ -60,6 +60,7 @@
"build": "node esbuild.js", "build": "node esbuild.js",
"test": "cross-env IS_TEST=true vitest", "test": "cross-env IS_TEST=true vitest",
"test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism",
"test:pipeline": "cross-env IS_TEST=true vitest run" "test:pipeline": "cross-env IS_TEST=true vitest run"
} }
} }
@@ -1564,7 +1564,7 @@ describe('rundownMutation.swap()', () => {
}); });
describe('rundownMutation.clone()', () => { describe('rundownMutation.clone()', () => {
it('clones an event and adds it to the rundown', () => { it('clones at the top level of the rundown', () => {
const testRundown = makeRundown({ const testRundown = makeRundown({
order: ['1'], order: ['1'],
entries: { entries: {
@@ -1583,7 +1583,7 @@ describe('rundownMutation.clone()', () => {
}); });
}); });
it('clones an event inside a group and adds it to the rundown', () => { it('clones an event inside a group', () => {
const testRundown = makeRundown({ const testRundown = makeRundown({
order: ['1'], order: ['1'],
entries: { entries: {
@@ -1621,6 +1621,55 @@ describe('rundownMutation.clone()', () => {
}); });
expect((testRundown.entries[newEntry.id] as OntimeGroup).entries[0]).not.toBe('1a'); expect((testRundown.entries[newEntry.id] as OntimeGroup).entries[0]).not.toBe('1a');
}); });
it('clones an entry from a group inside another group', () => {
const testRundown = makeRundown({
order: ['group1', 'group2'],
entries: {
group1: makeOntimeGroup({ id: 'group1', entries: ['event1'] }),
group2: makeOntimeGroup({ id: 'group2', entries: ['event2'] }),
event1: makeOntimeEvent({ id: 'event1', cue: 'nested-event', parent: 'group1' }),
event2: makeOntimeEvent({ id: 'event2', cue: 'nested-event', parent: 'group2' }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['event2'], {
after: 'event1',
}) as OntimeEvent;
// new event is added to group
expect(testRundown.entries['group1']).toMatchObject({
entries: ['event1', newEntry.id],
});
// new references the parent group
expect(newEntry.parent).toBe('group1');
// the flat rundown remains unchanged
expect(testRundown.order).toStrictEqual(['group1', 'group2']);
});
it('clones an event and inserts it before another event', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { before: '2' });
// Verify the rundown order is updated correctly
expect(testRundown.order).toStrictEqual(['1', newEntry.id, '2']);
// Verify the cloned entry is added to the rundown
expect(testRundown.entries[newEntry.id]).toMatchObject({
type: SupportedEntry.Event,
cue: 'event1',
parent: null,
});
});
}); });
describe('rundownMutation.group()', () => { describe('rundownMutation.group()', () => {
@@ -13,6 +13,7 @@ import {
duplicateRundown, duplicateRundown,
getInsertAfterId, getInsertAfterId,
hasChanges, hasChanges,
makeDeepClone,
} from '../rundown.utils.js'; } from '../rundown.utils.js';
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
@@ -265,7 +266,7 @@ describe('getInsertAfterId()', () => {
}); });
describe('duplicateRundown', () => { describe('duplicateRundown', () => {
it("duplicates a given rundown", () => { it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default']; const demoRundown = demoDb.rundowns['default'];
const title = 'Duplicated Rundown'; const title = 'Duplicated Rundown';
const duplicatedRundown = duplicateRundown(demoRundown, title); const duplicatedRundown = duplicateRundown(demoRundown, title);
@@ -275,10 +276,51 @@ describe('duplicateRundown', () => {
entries: expect.any(Object), entries: expect.any(Object),
order: expect.any(Array), order: expect.any(Array),
flatOrder: expect.any(Array), flatOrder: expect.any(Array),
}) });
expect(demoRundown.id).not.toEqual(duplicatedRundown.id); expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length); expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length); expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length); expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
}) });
}) });
describe('makeDeepClone()', () => {
it('deep clones a group along with its nested entries', () => {
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
const rundown = makeRundown({
entries: {
group1,
event1: makeOntimeEvent({ id: 'event1', title: 'Event 1', parent: 'group1' }),
event2: makeOntimeEvent({ id: 'event2', title: 'Event 2', parent: 'group1' }),
},
order: ['group1'],
flatOrder: ['group1', 'event1', 'event2'],
});
const { newGroup, nestedEntries } = makeDeepClone(group1, rundown);
expect(newGroup).toMatchObject({
id: expect.any(String),
title: 'Group 1 (copy)',
entries: [expect.any(String), expect.any(String)],
revision: 0,
});
expect(newGroup.id).not.toEqual('group1');
expect(newGroup.entries.length).toEqual(group1.entries.length);
expect(nestedEntries).toMatchObject([
{
id: expect.any(String),
title: 'Event 1',
parent: newGroup.id,
revision: 0,
},
{
id: expect.any(String),
title: 'Event 2',
parent: newGroup.id,
revision: 0,
},
]);
});
});
+68 -27
View File
@@ -24,23 +24,25 @@ import {
OntimeEvent, OntimeEvent,
PatchWithId, PatchWithId,
Rundown, Rundown,
InsertOptions,
} from 'ontime-types'; } from 'ontime-types';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils'; import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
applyPatchToEntry, applyPatchToEntry,
cloneGroup, cloneSimpleRundownEntry,
cloneEntry,
createGroup, createGroup,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getInsertAfterId,
getUniqueId, getUniqueId,
makeDeepClone,
} from './rundown.utils.js'; } from './rundown.utils.js';
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js'; import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
import { consoleError } from '../../utils/console.js';
/** /**
* The currently loaded rundown in cache * The currently loaded rundown in cache
@@ -171,6 +173,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry { function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
if (parent) { if (parent) {
// 1. inserting an entry inside a group // 1. inserting an entry inside a group
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) { if (afterId) {
const atEventsIndex = parent.entries.indexOf(afterId) + 1; const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
@@ -439,40 +446,74 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
* Inserts a clone of the given entry into the rundown * Inserts a clone of the given entry into the rundown
* Handles cloning children if the entry is a group * Handles cloning children if the entry is a group
*/ */
function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry { function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): OntimeEntry {
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
const newGroup = cloneGroup(entry, getUniqueId(rundown)); const { newGroup, nestedEntries } = makeDeepClone(entry, rundown);
const nestedIds: EntryId[] = [];
for (let i = 0; i < entry.entries.length; i++) { // insert all entries into the rundown
const nestedEntryId = entry.entries[i]; rundown.entries[newGroup.id] = newGroup;
const nestedEntry = rundown.entries[nestedEntryId]; for (let i = 0; i < nestedEntries.length; i++) {
if (!nestedEntry) { const nestedEntry = nestedEntries[i];
continue; rundown.entries[nestedEntry.id] = nestedEntry;
}
// clone the event and assign it to the new group
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId(rundown));
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newGroup.id;
nestedIds.push(newNestedEntry.id);
// we immediately insert the nested entries into the rundown
rundown.entries[newNestedEntry.id] = newNestedEntry;
} }
// indexes + 1 since we are inserting after the cloned group // by default we insert after the cloned element
const atIndex = rundown.order.indexOf(entry.id) + 1; let atIndex = rundown.order.indexOf(entry.id) + 1;
newGroup.entries = nestedIds; const referenceId = options?.after ?? options?.before;
newGroup.title = `${entry.title || 'Untitled'} (copy)`; if (referenceId) {
// trying to insert relatively to another entry
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry) {
if (options?.after) {
atIndex = rundown.order.indexOf(referenceId) + 1;
} else if (options?.before) {
atIndex = rundown.order.indexOf(referenceId);
}
}
}
rundown.entries[newGroup.id] = newGroup; // we only need to insert the group, the nested entries will be resolved by the rundown engine
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order); rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
return newGroup; return newGroup;
} else { } else {
const parent: OntimeGroup | null = entry.parent ? (rundown.entries[entry.parent] as OntimeGroup) : null; const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, parent);
let parent: OntimeGroup | null = null;
// trying to insert relatively to another entry, check that entries parent
const referenceId = options?.after ?? options?.before;
/**
* if we have a positioning reference, and that reference has a parent
* we need to maintain the same parent for the cloned entry
*/
if (referenceId) {
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
if (referenceEntry.parent) {
const maybeParent = rundown.entries[referenceEntry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
}
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
// if we have resolved a parent, we add it to the cloned entry
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
if (!after) {
after = entry.id;
}
return add(rundown, clonedEntry, after, parent);
} }
} }
@@ -28,6 +28,7 @@ import {
entryReorderValidator, entryReorderValidator,
entrySwapValidator, entrySwapValidator,
validateRundownMutation, validateRundownMutation,
clonePostValidator,
} from './rundown.validation.js'; } from './rundown.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js'; import { paramsWithId } from '../validation-utils/validationFunction.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -296,10 +297,14 @@ router.patch(
router.post( router.post(
'/:rundownId/clone/:id', '/:rundownId/clone/:id',
paramsWithId, paramsWithId,
clonePostValidator,
validateRundownMutation, validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => { async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try { try {
const rundown = await cloneEntry(req.params.id); const rundown = await cloneEntry(req.params.id, {
before: req.body?.before,
after: req.body?.after,
});
res.status(200).send(rundown); res.status(200).send(rundown);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -14,11 +14,15 @@ import {
Rundown, Rundown,
LogOrigin, LogOrigin,
ProjectRundowns, ProjectRundowns,
InsertOptions,
} from 'ontime-types'; } from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils'; import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js'; import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js'; import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
import { import {
createTransaction, createTransaction,
@@ -29,9 +33,6 @@ import {
} from './rundown.dao.js'; } from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js'; import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
/** /**
* creates a new entry with given data * creates a new entry with given data
@@ -347,17 +348,18 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
/** /**
* Clones an entry, ensuring that all dependencies are preserved * Clones an entry, ensuring that all dependencies are preserved
* Handles cloning children if the entry is a group
* @throws if the entry to clone does not exist * @throws if the entry to clone does not exist
*/ */
export async function cloneEntry(entryId: EntryId): Promise<Rundown> { export async function cloneEntry(entryId: EntryId, options: InsertOptions): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false }); const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const originalEntry = rundown.entries[entryId]; const originalEntry = rundown.entries[entryId];
if (!originalEntry) { if (!originalEntry) {
throw new Error('Did not find event to clone'); throw new Error('Could not find entry to clone');
} }
const newEntry = rundownMutation.clone(rundown, originalEntry); const newEntry = rundownMutation.clone(rundown, originalEntry, options);
const { rundown: rundownResult, rundownMetadata, revision } = commit(); const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects // schedule the side effects
@@ -373,7 +375,6 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
} else if (isOntimeDelay(newEntry)) { } else if (isOntimeDelay(newEntry)) {
notifyChanges(rundownMetadata, revision, { external: true }); notifyChanges(rundownMetadata, revision, { external: true });
} }
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
}); });
return rundownResult; return rundownResult;
@@ -396,20 +396,54 @@ export function cloneGroup(entry: OntimeGroup, newId: EntryId): OntimeGroup {
// in groups, we need to remove the events references // in groups, we need to remove the events references
newEntry.entries = []; newEntry.entries = [];
newEntry.title = `${entry.title || 'Untitled'} (copy)`;
newEntry.revision = 0; newEntry.revision = 0;
return newEntry; return newEntry;
} }
/** /**
* Receives an entry and chooses the correct cloning strategy * Clones a group and all its nested entries
*/ */
export function cloneEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry { export function makeDeepClone(
group: OntimeGroup,
rundown: Rundown,
): { newGroup: OntimeGroup; nestedEntries: OntimeEntry[] } {
const newGroupId = getUniqueId(rundown);
const newGroup = cloneGroup(group, newGroupId);
const nestedEntries: OntimeEntry[] = [];
const nestedEntryIds: EntryId[] = [];
for (let i = 0; i < group.entries.length; i++) {
const nestedEntryId = group.entries[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new group
const nestedEntryNewId = getUniqueId(rundown);
const newNestedEntry = cloneSimpleRundownEntry(nestedEntry, nestedEntryNewId);
(newNestedEntry as OntimeEvent | OntimeDelay | OntimeMilestone).parent = newGroup.id;
nestedEntryIds.push(nestedEntryNewId);
nestedEntries.push(newNestedEntry);
}
// update the new group with the nested entries
newGroup.entries = nestedEntryIds;
return { newGroup, nestedEntries };
}
/**
* Receives an entry and chooses the correct cloning strategy
* @throws if the source entry is unknown or a group
*/
export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId); return cloneEvent(entry, newId);
} else if (isOntimeDelay(entry)) { } else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId); return cloneDelay(entry, newId);
} else if (isOntimeGroup(entry)) {
return cloneGroup(entry, newId);
} else if (isOntimeMilestone(entry)) { } else if (isOntimeMilestone(entry)) {
return cloneMilestone(entry, newId); return cloneMilestone(entry, newId);
} }
@@ -42,6 +42,13 @@ export const entryPostValidator = [
requestValidationFunction, requestValidationFunction,
]; ];
export const clonePostValidator = [
body('after').optional().isString(),
body('before').optional().isString(),
requestValidationFunction,
];
export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction]; export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction];
export const entryBatchPutValidator = [ export const entryBatchPutValidator = [
@@ -26,17 +26,9 @@ test('Copy-paste', async ({ page }) => {
// assert // assert
await expect(page.getByTestId('entry-2')).toBeVisible(); await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test'); await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('5'); await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
// copy paste above //TODO: reintroduce the past above test
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '5' }).click();
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '5' }).press('Control+c');
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '5' }).press('Control+Shift+v');
// assert
await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4.1');
}); });
test('Move', async ({ page }) => { test('Move', async ({ page }) => {
@@ -3,15 +3,14 @@ import type { MaybeNumber } from '../../utils/utils.type.js';
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId }; export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
export type EventPostPayload = Partial<OntimeEntry> & { export type InsertOptions = {
after?: EntryId; after?: EntryId;
before?: EntryId; before?: EntryId;
}; }
export type TransientEventPayload = Partial<OntimeEntry> & { export type EventPostPayload = Partial<OntimeEntry> & InsertOptions;
after?: EntryId;
before?: EntryId; export type TransientEventPayload = Partial<OntimeEntry> & InsertOptions;
};
export type ProjectRundown = { export type ProjectRundown = {
id: string; id: string;
+1
View File
@@ -79,6 +79,7 @@ export type {
} from './api/ontime-controller/BackendResponse.type.js'; } from './api/ontime-controller/BackendResponse.type.js';
export type { export type {
EventPostPayload, EventPostPayload,
InsertOptions,
PatchWithId, PatchWithId,
ProjectRundown, ProjectRundown,
ProjectRundownsList, ProjectRundownsList,
+16 -25
View File
@@ -137,9 +137,6 @@ importers:
axios: axios:
specifier: ^1.12.2 specifier: ^1.12.2
version: 1.12.2 version: 1.12.2
babel-plugin-react-compiler:
specifier: 19.1.0-rc.3
version: 19.1.0-rc.3
csv-stringify: csv-stringify:
specifier: ^6.6.0 specifier: ^6.6.0
version: 6.6.0 version: 6.6.0
@@ -207,6 +204,9 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: 4.5.1 specifier: 4.5.1
version: 4.5.1(vite@6.3.1(@types/node@22.15.26)(jiti@2.4.2)(sass@1.92.0)(tsx@4.20.5)) version: 4.5.1(vite@6.3.1(@types/node@22.15.26)(jiti@2.4.2)(sass@1.92.0)(tsx@4.20.5))
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.56.0 version: 8.56.0
@@ -738,10 +738,6 @@ packages:
resolution: {integrity: sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==} resolution: {integrity: sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/types@7.27.7':
resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.2': '@babel/types@7.28.2':
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -2183,8 +2179,8 @@ packages:
axios@1.12.2: axios@1.12.2:
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
babel-plugin-react-compiler@19.1.0-rc.3: babel-plugin-react-compiler@1.0.0:
resolution: {integrity: sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA==} resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -5380,7 +5376,7 @@ snapshots:
'@babel/parser': 7.23.6 '@babel/parser': 7.23.6
'@babel/template': 7.22.15 '@babel/template': 7.22.15
'@babel/traverse': 7.23.6 '@babel/traverse': 7.23.6
'@babel/types': 7.27.7 '@babel/types': 7.28.2
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.4.1 debug: 4.4.1
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
@@ -5439,7 +5435,7 @@ snapshots:
'@babel/generator@7.27.5': '@babel/generator@7.27.5':
dependencies: dependencies:
'@babel/parser': 7.27.5 '@babel/parser': 7.27.5
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@jridgewell/gen-mapping': 0.3.8 '@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
jsesc: 3.1.0 jsesc: 3.1.0
@@ -5594,7 +5590,7 @@ snapshots:
'@babel/helpers@7.27.4': '@babel/helpers@7.27.4':
dependencies: dependencies:
'@babel/template': 7.27.2 '@babel/template': 7.27.2
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@babel/helpers@7.28.3': '@babel/helpers@7.28.3':
dependencies: dependencies:
@@ -5610,7 +5606,7 @@ snapshots:
'@babel/parser@7.23.6': '@babel/parser@7.23.6':
dependencies: dependencies:
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@babel/parser@7.27.5': '@babel/parser@7.27.5':
dependencies: dependencies:
@@ -5654,7 +5650,7 @@ snapshots:
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.27.1
'@babel/parser': 7.27.5 '@babel/parser': 7.27.5
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@babel/traverse@7.23.6': '@babel/traverse@7.23.6':
dependencies: dependencies:
@@ -5677,7 +5673,7 @@ snapshots:
'@babel/generator': 7.27.5 '@babel/generator': 7.27.5
'@babel/parser': 7.27.5 '@babel/parser': 7.27.5
'@babel/template': 7.27.2 '@babel/template': 7.27.2
'@babel/types': 7.27.7 '@babel/types': 7.28.2
debug: 4.4.1 debug: 4.4.1
globals: 11.12.0 globals: 11.12.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -5718,11 +5714,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
'@babel/types@7.27.7':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/types@7.28.2': '@babel/types@7.28.2':
dependencies: dependencies:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
@@ -6541,7 +6532,7 @@ snapshots:
'@svgr/hast-util-to-babel-ast@8.0.0': '@svgr/hast-util-to-babel-ast@8.0.0':
dependencies: dependencies:
'@babel/types': 7.27.7 '@babel/types': 7.28.2
entities: 4.5.0 entities: 4.5.0
'@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.5.3))': '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.5.3))':
@@ -6607,16 +6598,16 @@ snapshots:
'@types/babel__generator@7.6.8': '@types/babel__generator@7.6.8':
dependencies: dependencies:
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@types/babel__template@7.4.4': '@types/babel__template@7.4.4':
dependencies: dependencies:
'@babel/parser': 7.27.5 '@babel/parser': 7.27.5
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@types/babel__traverse@7.20.4': '@types/babel__traverse@7.20.4':
dependencies: dependencies:
'@babel/types': 7.27.7 '@babel/types': 7.28.2
'@types/body-parser@1.19.2': '@types/body-parser@1.19.2':
dependencies: dependencies:
@@ -7223,7 +7214,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
babel-plugin-react-compiler@19.1.0-rc.3: babel-plugin-react-compiler@1.0.0:
dependencies: dependencies:
'@babel/types': 7.28.2 '@babel/types': 7.28.2