diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 23714f4a7..1a6e63419 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -87,6 +87,13 @@ export async function requestApplyDelay(delayId: EntryId): Promise> { + return axios.post(`${rundownPath}/clone/${entryId}`); +} + /** * HTTP request for dissolving of a block */ diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index f6bd8be3f..942dc6f55 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -20,6 +20,7 @@ import { deleteEntries, patchReorderEntry, postAddEntry, + postCloneEntry, putBatchEditEvents, putEditEntry, ReorderEntry, @@ -164,6 +165,29 @@ export const useEntryActions = () => { ], ); + /** + * 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 @@ -735,6 +759,7 @@ export const useEntryActions = () => { addEntry, applyDelay, batchUpdateEvents, + clone, deleteEntry, deleteAllEntries, dissolveBlock, diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/clone.test.ts similarity index 97% rename from apps/client/src/common/utils/__tests__/eventsManager.test.ts rename to apps/client/src/common/utils/__tests__/clone.test.ts index b662c477d..97fd32d9b 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/clone.test.ts @@ -1,6 +1,6 @@ import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types'; -import { cloneEvent } from '../eventsManager'; +import { cloneEvent } from '../clone'; describe('cloneEvent()', () => { it('creates a stem from a given event', () => { diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/clone.ts similarity index 100% rename from apps/client/src/common/utils/eventsManager.ts rename to apps/client/src/common/utils/clone.ts diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index cb768f0c6..704353456 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -37,7 +37,7 @@ import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore'; -import { cloneEvent } from '../../common/utils/eventsManager'; +import { cloneEvent } from '../../common/utils/clone'; import BlockBlock from './block-block/BlockBlock'; import BlockEnd from './block-block/BlockEnd'; @@ -329,7 +329,7 @@ export default function Rundown({ data }: RundownProps) { }; if (sortableData.length < 1) { - return insertAtId({ type }, cursor)} />; + return addEntry({ type })} />; } // 1. gather presentation options diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 3d2cadd31..d20948af3 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -12,7 +12,7 @@ import { import { useEntryActions } from '../../common/hooks/useEntryAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import { useEmitLog } from '../../common/stores/logger'; -import { cloneEvent } from '../../common/utils/eventsManager'; +import { cloneEvent } from '../../common/utils/clone'; import DelayBlock from './delay-block/DelayBlock'; import EventBlock from './event-block/EventBlock'; diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index 3e88d4907..adaa4bc34 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,6 +1,14 @@ import { useRef } from 'react'; -import { IoChevronDown, IoChevronUp, IoEllipsisHorizontal, IoReorderTwo } from 'react-icons/io5'; -import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; +import { + IoChevronDown, + IoChevronUp, + IoDuplicateOutline, + IoEllipsisHorizontal, + IoFolderOpenOutline, + IoReorderTwo, + IoTrash, +} from 'react-icons/io5'; +import { IconButton, Menu, MenuButton, MenuItem, MenuList, Portal } from '@chakra-ui/react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { EntryId, OntimeBlock } from 'ontime-types'; @@ -23,7 +31,7 @@ interface BlockBlockProps { export default function BlockBlock(props: BlockBlockProps) { const { data, hasCursor, collapsed, onCollapse } = props; const handleRef = useRef(null); - const { dissolveBlock } = useEntryActions(); + const { clone, dissolveBlock, deleteEntry } = useEntryActions(); const { attributes: dragAttributes, @@ -52,6 +60,8 @@ export default function BlockBlock(props: BlockBlockProps) { cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default', }; + const hasChildren = data.events.length > 0; + return (
- - dissolveBlock(data.id)}>Dissolve Block - + + + } onClick={() => clone(data.id)}> + Clone Block + + {hasChildren && ( + } onClick={() => dissolveBlock(data.id)}> + Dissolve Block + + )} + } onClick={() => deleteEntry([data.id])}> + Delete Block + + + onCollapse(!collapsed, data.id)} color='#e2e2e2' // $gray-200 variant='ontime-ghosted' diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx index d3214f3f7..3e924282b 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx @@ -3,7 +3,7 @@ import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; import { isOntimeEvent, SupportedEntry } from 'ontime-types'; import { useEntryActions } from '../../../../common/hooks/useEntryAction'; -import { cloneEvent } from '../../../../common/utils/eventsManager'; +import { cloneEvent } from '../../../../common/utils/clone'; interface CuesheetTableMenuActionsProps { eventId: string; diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts index cb7fb622e..7c8445473 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -1,10 +1,10 @@ import express from 'express'; import { getAll, deleteWithId, deleteAll } from './report.controller.js'; -import { paramsMustHaveEventId } from '../rundown/rundown.validation.js'; +import { paramsMustHaveEntryId } from '../rundown/rundown.validation.js'; export const router = express.Router(); router.get('/', getAll); router.delete('/all', deleteAll); -router.delete('/:eventId', paramsMustHaveEventId, deleteWithId); +router.delete('/:eventId', paramsMustHaveEntryId, deleteWithId); diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index d00d9a5a4..ac743bbe3 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -15,6 +15,7 @@ import { groupEntries, reorderEntry, swapEvents, + cloneEntry, } from '../../services/rundown-service/RundownService.js'; import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; @@ -120,7 +121,7 @@ export async function rundownSwap(req: Request, res: Response) { try { - await applyDelay(req.params.eventId); + await applyDelay(req.params.entryId); res.status(200).send({ message: 'Delay applied' }); } catch (error) { const message = getErrorMessage(error); @@ -128,9 +129,19 @@ export async function rundownApplyDelay(req: Request, res: Response) { + try { + const newRundown = await cloneEntry(req.params.entryId); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + export async function rundownDissolveBlock(req: Request, res: Response) { try { - const newRundown = await dissolveBlock(req.params.eventId); + const newRundown = await dissolveBlock(req.params.entryId); res.status(200).send(newRundown); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index cd2dcb4a5..44e1bcc97 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -5,6 +5,7 @@ import { rundownAddToBlock, rundownApplyDelay, rundownBatchPut, + rundownCloneEntry, rundownDelete, rundownDissolveBlock, rundownGetAll, @@ -16,7 +17,7 @@ import { rundownSwap, } from './rundown.controller.js'; import { - paramsMustHaveEventId, + paramsMustHaveEntryId, rundownArrayOfIds, rundownBatchPutValidator, rundownPostValidator, @@ -29,7 +30,7 @@ export const router = express.Router(); router.get('/', rundownGetAll); router.get('/current', rundownGetCurrent); -router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend +router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend router.post('/', rundownPostValidator, rundownPost); @@ -38,8 +39,9 @@ router.put('/batch', rundownBatchPutValidator, rundownBatchPut); router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); -router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); -router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock); +router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay); +router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry); +router.post('/dissolve/:entryId', paramsMustHaveEntryId, rundownDissolveBlock); router.post('/group', rundownArrayOfIds, rundownAddToBlock); router.delete('/', rundownArrayOfIds, deletesEventById); diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index bdd07a618..eb018d8af 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -57,8 +57,8 @@ export const rundownSwapValidator = [ }, ]; -export const paramsMustHaveEventId = [ - param('eventId').exists(), +export const paramsMustHaveEntryId = [ + param('entryId').exists(), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 5c618210e..413571b5c 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -21,7 +21,7 @@ import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; -import { getInsertionPosition } from './rundownUtils.js'; +import { getPreviousId } from './rundownUtils.js'; type CompleteEntry = T extends Partial @@ -70,11 +70,12 @@ export async function addEvent(eventData: EventPostPayload): Promise { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -625,13 +626,60 @@ describe('generate() v4', () => { }); describe('add() mutation', () => { - test('adds an event to the rundown', () => { + test('adds an event an empty rundown', () => { const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const rundown = makeRundown({}); - const { newRundown } = add({ atIndex: 0, entry: mockEvent, parent: null, rundown }); + const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown }); expect(newRundown.order.length).toBe(1); expect(newRundown.entries['mock']).toMatchObject(mockEvent); }); + + test('adds an event at the top if no afterId is given', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1'], + order: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: '1' }), + }, + }); + const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown }); + expect(newRundown.order).toStrictEqual(['mock', '1']); + expect(newRundown.flatOrder).toStrictEqual(['mock', '1']); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the top of the block if no after is given', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1', '1a'], + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1' }), + '1a': makeOntimeEvent({ id: '1a', parent: '1' }), + }, + }); + const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: '1', rundown }); + expect(newRundown.order).toStrictEqual(['1']); + expect(newRundown.flatOrder).toStrictEqual(['1', 'mock', '1a']); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the a given location inside a block', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1', '1a'], + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1' }), + '1a': makeOntimeEvent({ id: '1a', parent: '1' }), + }, + }); + const { newRundown } = add({ afterId: '1a', entry: mockEvent, parent: '1', rundown }); + expect(newRundown.order).toStrictEqual(['1']); + expect(newRundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); + }); }); describe('remove() mutation', () => { @@ -767,6 +815,65 @@ describe('reorder() mutation', () => { }); }); +describe('clone() mutation', () => { + it('clones an event and adds it to the rundown', () => { + const rundown = makeRundown({ + order: ['1'], + flatOrder: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), + }, + }); + + const { newRundown, newEvent } = clone({ rundown, entryId: '1' }); + + expect(newRundown.order).toStrictEqual(['1', newEvent!.id]); + expect(newRundown.flatOrder).toStrictEqual(['1', newEvent!.id]); + }); + + it('clones an event inside a block and adds it to the rundown', () => { + const rundown = makeRundown({ + order: ['1'], + flatOrder: ['1', '1a'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['1a'] }), + '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), + }, + }); + + const { newRundown, newEvent } = clone({ rundown, entryId: '1a' }); + + expect(newRundown.order).toStrictEqual(['1']); + expect(newRundown.flatOrder).toStrictEqual(['1', '1a', newEvent!.id]); + expect(newRundown.entries['1']).toMatchObject({ events: ['1a', newEvent!.id] }); + expect(newRundown.entries[newEvent!.id]).toMatchObject({ + type: SupportedEntry.Event, + parent: '1', + cue: 'nested', + }); + }); + + it('clones a block and its nested elements', () => { + const rundown = makeRundown({ + order: ['1'], + flatOrder: ['1', '1a'], + entries: { + '1': makeOntimeBlock({ id: '1', title: 'top', events: ['1a'] }), + '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), + }, + }); + + const { newRundown, newEvent } = clone({ rundown, entryId: '1' }); + + expect(newRundown.order).toStrictEqual(['1', newEvent!.id]); + expect(newRundown.flatOrder).toStrictEqual(['1', '1a', expect.any(String), expect.any(String)]); + expect(newRundown.entries[newEvent!.id]).toMatchObject({ + type: SupportedEntry.Block, + events: [expect.any(String)], + }); + }); +}); + describe('dissolveBlock() mutation', () => { it('should correctly dissolve a block into its events', () => { const rundown = makeRundown({ diff --git a/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts new file mode 100644 index 000000000..eefd30140 --- /dev/null +++ b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts @@ -0,0 +1,26 @@ +import { getPreviousId } from '../rundownUtils.js'; + +// Mock cache module +vi.mock('../rundownCache.js', () => ({ + getEventOrder: () => ({ + flatOrder: ['a', 'b', 'c', 'd'], + }), +})); + +describe('getPreviousId', () => { + it('returns afterId if provided', () => { + expect(getPreviousId('b')).toBe('b'); + }); + + it('returns the previous id before beforeId if provided', () => { + expect(getPreviousId(undefined, 'c')).toBe('b'); + }); + + it('returns undefined if neither afterId nor beforeId is provided', () => { + expect(getPreviousId()).toBeUndefined(); + }); + + it('returns undefined if beforeId is not found', () => { + expect(getPreviousId(undefined, 'z')).toBeUndefined(); + }); +}); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index cbf2b764b..9a50f81ab 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -27,7 +27,14 @@ import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.j import type { RundownMetadata } from './rundown.types.js'; import { apply } from './delayUtils.js'; -import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js'; +import { + cloneBlock, + cloneEntry, + hasChanges, + isDataStale, + makeRundownMetadata, + type ProcessedRundownMetadata, +} from './rundownCache.utils.js'; let currentRundownId: EntryId = ''; let currentRundown: Rundown = { @@ -271,6 +278,7 @@ export function getMetadata(): Readonly export type RundownOrder = { order: EntryId[]; + flatOrder: EntryId[]; timedEventsOrder: EntryId[]; playableEventsOrder: EntryId[]; }; @@ -284,6 +292,7 @@ export function getEventOrder(): Readonly { } return { order: currentRundown.order, + flatOrder: currentRundown.flatOrder, timedEventsOrder: rundownMetadata.timedEventOrder, playableEventsOrder: rundownMetadata.playableEventOrder, }; @@ -333,41 +342,69 @@ export function mutateCache(mutation: MutatingFn) { return scopedMutation; } -type AddArgs = MutationParams<{ atIndex: number; parent: EntryId | null; entry: OntimeEntry }>; +type AddArgs = MutationParams<{ afterId?: string; parent: EntryId | null; entry: OntimeEntry }>; /** - * Add entry to rundown + * Add entry to rundown, handles the following cases: + * - 1. add entry in block, after a given entry + * - 2. add entry in block, at the beginning + * - 3. add entry to the rundown, after a given entry + * - 4. add entry to the rundown, at the beginning */ -export function add({ rundown, atIndex, parent, entry }: AddArgs): Required { - const newEntry: OntimeEntry = { ...entry }; - - rundown.entries[newEntry.id] = newEntry; - +export function add({ rundown, afterId, parent, entry }: AddArgs): Required { if (parent) { const parentBlock = rundown.entries[parent] as OntimeBlock; - parentBlock.events = insertAtIndex(atIndex, newEntry.id, parentBlock.events); + if (afterId) { + const atEventsIndex = parentBlock.events.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events); + const atFlatIndex = rundown.flatOrder.indexOf(parent) + 1; + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } } else { - rundown.order = insertAtIndex(atIndex, newEntry.id, rundown.order); + if (afterId) { + const atOrderIndex = rundown.order.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + rundown.order = insertAtIndex(0, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder); + } } + // either way, we insert the entry into the rundown + rundown.entries[entry.id] = entry; setIsStale(); - return { newRundown: rundown, changeList: [], newEvent: newEntry, didMutate: true }; + return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true }; } type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>; /** * Remove entries in a rundown - * It needs to ensure that the parent block is updated + * It handles element relationships specifically when dealing with nested items + * - when removing a nested item, remove the reference from the parent block + * - when removing a block, remove all nested items */ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { - let didMutate = false; + /** + * changelist will hold a list of entries that need to be removed + * it will then be returned to the caller as a list of actually deleted entries + */ + const changeList: EntryId[] = []; for (let i = 0; i < eventIds.length; i++) { const entry = rundown.entries[eventIds[i]]; - if (isOntimeBlock(entry) || !entry.parent) { - // top level events can simply be removed from the order - // the deletion process and the flatOrder are handled globally - rundown.order = rundown.order.filter((id) => id !== eventIds[i]); - } else { + // add the top level entry to the changeList + changeList.push(entry.id); + + if (isOntimeBlock(entry)) { + // for ontime blocks, we need to iterate through the children and delete them + changeList.concat([...entry.events]); + } else if (entry.parent) { + // at this point, we are handling entries inside a block, so we need to remove the references const parentBlock = rundown.entries[entry.parent] as OntimeBlock; const parentEvents = parentBlock.events.filter((id) => id !== eventIds[i]); @@ -383,14 +420,19 @@ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { }, }); } - - didMutate = true; - rundown.flatOrder = rundown.flatOrder.filter((id) => id !== eventIds[i]); - delete rundown.entries[eventIds[i]]; } + // delete all entries in the changeList + for (let i = 0; i < changeList.length; i++) { + const entryId = changeList[i]; + rundown.order = rundown.order.filter((id) => id !== entryId); + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); + delete rundown.entries[entryId]; + } + + const didMutate = changeList.length > 0; if (didMutate) setIsStale(); - return { newRundown: rundown, didMutate }; + return { newRundown: rundown, didMutate, changeList }; } /** @@ -517,6 +559,55 @@ export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn return { newRundown: rundown, didMutate: true }; } +type CloneEntryArgs = MutationParams<{ entryId: EntryId }>; +/** + * Apply a delay + * Mutates the given rundown + */ +export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn { + const entry = rundown.entries[entryId]; + if (!entry) { + throw new Error('Entry not found'); + } + + if (isOntimeBlock(entry)) { + const newBlock = cloneBlock(entry, getUniqueId()); + const nestedIds: EntryId[] = []; + + for (let i = 0; i < entry.events.length; i++) { + const nestedEntryId = entry.events[i]; + const nestedEntry = rundown.entries[nestedEntryId]; + if (!nestedEntry) { + continue; + } + + // clone the event and assign it to the new block + const newNestedEntry = cloneEntry(nestedEntry, getUniqueId()); + (newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.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 block + const atIndex = rundown.order.indexOf(entryId) + 1; + // we need to find the index of the last entry + const lastNestedIdInOriginal = entry.events.at(-1) ?? '0'; + const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1; + + newBlock.events = nestedIds; + newBlock.title = `${entry.title} (copy)`; + + rundown.entries[newBlock.id] = newBlock; + rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order); + rundown.flatOrder = mergeAtIndex(flatIndex, [newBlock.id, ...nestedIds], rundown.flatOrder); + + return { newRundown: rundown, didMutate: true, newEvent: newBlock }; + } else { + return add({ rundown, afterId: entryId, parent: entry.parent, entry: cloneEntry(entry, getUniqueId()) }); + } +} + type DissolveBlockArgs = MutationParams<{ blockId: EntryId }>; /** * Deletes a block and moves all its children to the top level order diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index 08e58e74a..672430a8c 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -10,6 +10,8 @@ import { isOntimeDelay, PlayableEvent, RundownEntries, + OntimeDelay, + OntimeBlock, } from 'ontime-types'; import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; @@ -286,3 +288,37 @@ function processEntry( return { processedData, processedEntry: currentEntry }; } + +export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent { + const newEntry = structuredClone(entry); + newEntry.id = newId; + newEntry.revision = 0; + return newEntry; +} + +export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay { + const newEntry = structuredClone(entry); + newEntry.id = newId; + return newEntry; +} + +export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock { + const newEntry = structuredClone(entry); + newEntry.id = newId; + + // in blocks, we need to remove the events references + newEntry.events = []; + newEntry.revision = 0; + return newEntry; +} + +export function cloneEntry(entry: T, newId: EntryId): T { + if (isOntimeEvent(entry)) { + return cloneEvent(entry, newId) as T; + } else if (isOntimeDelay(entry)) { + return cloneDelay(entry, newId) as T; + } else if (entry.type === 'block') { + return cloneBlock(entry as OntimeBlock, newId) as T; + } + throw new Error(`Unsupported entry type for cloning: ${entry}`); +} diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index 3e4531d66..ac51f62c3 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -6,7 +6,6 @@ import { EntryId, RundownEntries, ProjectRundowns, - OntimeBlock, } from 'ontime-types'; import * as cache from './rundownCache.js'; @@ -175,37 +174,21 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): return rundowns[rundownId]; } -export function getInsertionPosition( - parentId: EntryId | null, - afterId?: EntryId, - beforeId?: EntryId, -): { atIndex: number; afterId: EntryId | undefined } { +/** + * Receives an insertion order and returns the reference to an event ID + * after which we will insert the new event + */ +export function getPreviousId(afterId?: EntryId, beforeId?: EntryId): EntryId | undefined { if (afterId) { - const order = selectOrderList(parentId); - return { - atIndex: order.findIndex((id) => id === afterId) + 1, - afterId, - }; + return afterId; } if (beforeId) { - const order = selectOrderList(parentId); - const atIndex = order.findIndex((id) => id === beforeId); - return { - atIndex, - afterId: order[atIndex - 1] ?? null, - }; + const flatOrder = cache.getEventOrder().flatOrder; + const atIndex = flatOrder.findIndex((id) => id === beforeId); + if (atIndex < 1) return undefined; + return flatOrder[atIndex - 1]; } - return { - atIndex: 0, - afterId: undefined, - }; - - function selectOrderList(parentId: EntryId | null) { - if (parentId) { - return (getEntryWithId(parentId) as OntimeBlock).events; - } - return cache.getEventOrder().order; - } + return; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index cb6d09191..81f5e66a7 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -1,5 +1,6 @@ import { EndAction, + EntryId, isOntimeEvent, isPlayableEvent, LogOrigin, @@ -238,7 +239,7 @@ class RuntimeService { * Called when the underlying data has changed, * we check if the change affects the runtime */ - public notifyOfChangedEvents(affectedIds?: string[]) { + public notifyOfChangedEvents(affectedIds?: EntryId[]) { const state = runtimeState.getState(); const hasLoadedElements = state.eventNow !== null || state.eventNext !== null; if (!hasLoadedElements) { diff --git a/packages/utils/src/common/arrayUtils.test.ts b/packages/utils/src/common/arrayUtils.test.ts index bc0a56474..8f91775de 100644 --- a/packages/utils/src/common/arrayUtils.test.ts +++ b/packages/utils/src/common/arrayUtils.test.ts @@ -1,4 +1,4 @@ -import { deleteAtIndex, insertAtIndex, reorderArray } from './arrayUtils.js'; +import { deleteAtIndex, insertAtIndex, mergeAtIndex, reorderArray } from './arrayUtils.js'; describe('insertAtIndex', () => { it('should insert an item at the beginning of the array', () => { @@ -23,7 +23,34 @@ describe('insertAtIndex', () => { const array = [1, 2, 3]; const result = insertAtIndex(1, 5, array); expect(result).toEqual([1, 5, 2, 3]); - expect(array).toEqual([1, 2, 3]); // Original array should remain unchanged + expect(array).toEqual([1, 2, 3]); + }); +}); + +describe('mergeAtIndex', () => { + it('should insert an item at the beginning of the array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(0, ['a', 'b'], array); + expect(result).toEqual(['a', 'b', '1', '2', '3']); + }); + + it('should insert an item at the end of the array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(3, ['a', 'b'], array); + expect(result).toEqual(['1', '2', '3', 'a', 'b']); + }); + + it('should insert an item in the middle of the array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(2, ['a', 'b'], array); + expect(result).toEqual(['1', '2', 'a', 'b', '3']); + }); + + it('should return a new array and not modify the original array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(5, ['a', 'b'], array); + expect(result).toEqual(['1', '2', '3', 'a', 'b']); + expect(array).toEqual(['1', '2', '3']); }); });