diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 1ca6b9f4c..f1dfec996 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -56,9 +56,9 @@ export async function putBatchEditEvents(data: BatchEditEntry): Promise { */ const _reorderEntryMutation = useMutation({ mutationFn: patchReorderEntry, - // we optimistically update here - onMutate: async (data) => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); - - if (previousData) { - // optimistically update object - const newOrder = reorderArray(previousData.order, data.from, data.to); - queryClient.setQueryData(RUNDOWN, { - id: previousData.id, - title: previousData.title, - order: newOrder, - flatOrder: previousData.flatOrder, - entries: previousData.entries, - revision: -1, - }); - } - - // Return a context with the previous and new events - return { previousData }; - }, - - // Mutation fails, rollback undoes optimist update - onError: (_error, _data, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); - }, - - // Mutation finished, we update the rundown with the response - onSuccess: (response) => { - if (!response.data) return; - - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - }, - // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { @@ -674,12 +630,12 @@ export const useEntryActions = () => { * Reorders a given entry */ const reorderEntry = useCallback( - async (entryId: string, from: number, to: number) => { + async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { try { const reorderObject: ReorderEntry = { - eventId: entryId, - from, - to, + entryId, + destinationId, + order, }; await _reorderEntryMutation.mutateAsync(reorderObject); } catch (error) { @@ -689,6 +645,30 @@ export const useEntryActions = () => { [_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 @@ -765,6 +745,7 @@ export const useEntryActions = () => { ungroup, getEntryById, groupEntries, + move, reorderEntry, swapEvents, updateEntry, diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 27001a5bf..d21747cec 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -42,7 +42,7 @@ import { cloneEvent } from '../../common/utils/clone'; import BlockBlock from './block-block/BlockBlock'; import BlockEnd from './block-block/BlockEnd'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; -import { getNextId, getPreviousId, makeRundownMetadata, makeSortableList } from './rundown.utils'; +import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -188,19 +188,26 @@ export default function Rundown({ data }: RundownProps) { ); const moveEntry = useCallback( - (cursor: string | null, direction: 'up' | 'down') => { - if (order.length < 2 || cursor == null) { + (cursor: EntryId | null, direction: 'up' | 'down') => { + if (sortableData.length < 2 || cursor == null) { return; } - const destinationId = direction === 'up' ? getPreviousId(cursor, sortableData) : getNextId(cursor, sortableData); - if (direction === 'up' && destinationId === null) { - reorderEntry(cursor, cursor, 'before'); - } else if (destinationId !== null) { - reorderEntry(cursor, destinationId); + const { destinationId, order, isBlock } = + direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries); + + if (!destinationId) { + return; } + + // if we are moving into a block, we need to make sure it is expanded + if (isBlock) { + handleCollapseGroup(false, destinationId); + } + + reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert'); }, - [order.length, sortableData, reorderEntry], + [sortableData, reorderEntry], ); // shortcuts @@ -285,17 +292,33 @@ export default function Rundown({ data }: RundownProps) { const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (over?.id) { - if (active.id !== over?.id) { - // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates - setSortableData((currentEntries) => { - const fromIndex = active.data.current?.sortable.index; - const toIndex = over.data.current?.sortable.index; - return reorderArray(currentEntries, fromIndex, toIndex); - }); - reorderEntry(active.id as string, over.id as string, 'before'); - } + if (!over?.id || active.id === over.id) { + return; } + + const fromIndex = active.data.current?.sortable.index; + const toIndex = over.data.current?.sortable.index; + + // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates + setSortableData((currentEntries) => { + return reorderArray(currentEntries, fromIndex, toIndex); + }); + + let destinationId = over.id as EntryId; + let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before'; + + /** + * We need to specially handle the end blocks + * Dragging before and end block will add the entry to the end of the block + * Dragging after an end block will add the event after the block itself + */ + if (destinationId.startsWith('end-')) { + destinationId = destinationId.replace('end-', ''); + // if we are moving before the end, we use the insert operation + order = 'insert'; + } + + reorderEntry(active.id as EntryId, destinationId, order); }; /** diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index 99998e1bd..d94ddff85 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -1,6 +1,6 @@ -import { OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; +import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; -import { makeRundownMetadata, makeSortableList } from '../rundown.utils'; +import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils'; describe('makeRundownMetadata()', () => { it('processes nested rundown data', () => { @@ -333,3 +333,67 @@ describe('makeSortableList()', () => { expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']); }); }); + +describe('moveUp()', () => { + const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3']; + const entries = { + event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent, + event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent, + block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock, + event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent, + block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock, + event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent, + }; + + it('moves an event up in the list', () => { + const result = moveUp('event2', sortableData, entries); + expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false }); + }) + + it.todo('disallows nesting blocks', () => { + const result = moveUp('block2', sortableData, entries); + expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false }); + }) + + it('moves an event into a block', () => { + const result = moveUp('event3', sortableData, entries); + expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true }); + }) + + it('moving up from top is noop', () => { + const result = moveUp('event1', sortableData, entries); + expect(result).toMatchObject({ destinationId: null }); + }) +}); + +describe('moveDown()', () => { + const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3']; + const entries = { + event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent, + event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent, + block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock, + event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent, + block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock, + event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent, + }; + + it('moves an event down in the list', () => { + const result = moveDown('event1', sortableData, entries); + expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false }); + }) + + it.todo('disallows nesting blocks', () => { + const result = moveDown('block1', sortableData, entries); + expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false }); + }) + + it('moves an event into a block', () => { + const result = moveDown('event2', sortableData, entries); + expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true }); + }) + + it('moving down from bottom is noop', () => { + const result = moveDown('event3', sortableData, entries); + expect(result).toMatchObject({ destinationId: null }); + }) +}); \ No newline at end of file diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index 90815173e..58159c6d1 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -164,7 +164,85 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu return targetType == 'block'; } -export function getNextId(entryId: EntryId, sortableData: EntryId[]): MaybeString { +/** + * Calculates destinations for an entry moving one position up in the rundown + * - Handles noops + * - Handles moving in and out of blocks + * TODO: handle moving blocks + */ +export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) { + const previousEntryId = getPreviousId(entryId, sortableData); + + // the user is moving up at the top of the list + if (!previousEntryId) { + return { destinationId: null, order: 'before', isBlock: false }; + } + + if (previousEntryId.startsWith('end-')) { + const entry = entries[entryId]; + if (isOntimeBlock(entry)) { + // if we are moving a block, we cannot insert it + return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false }; + } + // insert in the block ID will add to the end of the block events + return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true }; + } + + // @ts-expect-error -- we safeguard the entry not having a parent property + return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) }; +} + +/** + * Calculates destinations for an entry moving one position down in the rundown + * - Handles noops + * - Handles moving in and out of blocks + * TODO: handle moving blocks + */ +export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) { + const nextEntryId = getNextId(entryId, sortableData); + + // the user is moving down at the end of the list + if (!nextEntryId) { + return { destinationId: null, order: 'after', isBlock: false }; + } + + if (nextEntryId.startsWith('end-')) { + // move outside the block + return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false }; + } + + /** + * If the next entry is a block + * - 1. blocks need to skip over it + * - 2. if the block has children, we insert before the first child + * - 3. if the block is empty, we insert into the block + */ + if (isOntimeBlock(entries[nextEntryId])) { + const entry = entries[entryId]; + + if (isOntimeBlock(entry)) { + // 1. if we are moving a block, we cannot insert it + return { destinationId: nextEntryId, order: 'after', isBlock: false }; + } + + const firstBlockChild = entries[nextEntryId].events.at(0); + if (firstBlockChild) { + // 2. add before the first child of the block + return { destinationId: firstBlockChild, order: 'before', isBlock: true }; + } else { + // 3. or insert into an empty block + return { destinationId: nextEntryId, order: 'insert', isBlock: true }; + } + } + + return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) }; +} + +/** + * Utility function gets the ID if the next entry in the list + * returns null if none is found + */ +function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null { const currentIndex = sortableData.indexOf(entryId); if (currentIndex === -1 || currentIndex === sortableData.length - 1) { // No next ID if not found or at the end @@ -173,7 +251,11 @@ export function getNextId(entryId: EntryId, sortableData: EntryId[]): MaybeStrin return sortableData[currentIndex + 1]; } -export function getPreviousId(entryId: EntryId, sortableData: EntryId[]): MaybeString { +/** + * Utility function gets the ID if the previous entry in the list + * returns null if none is found + */ +function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null { const currentIndex = sortableData.indexOf(entryId); if (currentIndex < 1) { // No previous ID found or at the beginning 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 3e924282b..aee6eb793 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 @@ -13,7 +13,7 @@ interface CuesheetTableMenuActionsProps { export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) { const { eventId, entryIndex, showModal } = props; - const { addEntry, getEntryById, reorderEntry, deleteEntry } = useEntryActions(); + const { addEntry, getEntryById, move, deleteEntry } = useEntryActions(); const handleCloneEvent = () => { const currentEvent = getEntryById(eventId); @@ -45,14 +45,10 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions Clone event - } - onClick={() => reorderEntry(eventId, entryIndex, entryIndex - 1)} - > + } onClick={() => move(eventId, 'up')}> Move up - } onClick={() => reorderEntry(eventId, entryIndex, entryIndex + 1)}> + } onClick={() => move(eventId, 'down')}> Move down } onClick={() => deleteEntry([eventId])}> diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index ce84fdfcf..0f173d385 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -13,7 +13,6 @@ import { editEvent, ungroupEntries, groupEntries, - reorderEntry, swapEvents, cloneEntry, } from '../../services/rundown-service/RundownService.js'; @@ -89,21 +88,6 @@ export async function rundownBatchPut(req: Request, res: Response) { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const { eventId, from, to } = req.body; - const newRundown = await reorderEntry(eventId, from, to); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - export async function rundownSwap(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index e2cb3ccf9..e1c4451c5 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -1,5 +1,11 @@ +import { ErrorResponse, Rundown } from 'ontime-types'; +import { getErrorMessage } from 'ontime-utils'; + +import type { Request, Response } from 'express'; import express from 'express'; +import { reorderEntry } from '../../services/rundown-service/RundownService.js'; + import { deletesEventById, rundownAddToBlock, @@ -13,7 +19,6 @@ import { rundownGetCurrent, rundownPost, rundownPut, - rundownReorder, rundownSwap, } from './rundown.controller.js'; import { @@ -37,7 +42,16 @@ router.post('/', rundownPostValidator, rundownPost); router.put('/', rundownPutValidator, rundownPut); router.put('/batch', rundownBatchPutValidator, rundownBatchPut); -router.patch('/reorder/', rundownReorderValidator, rundownReorder); +router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response) => { + try { + const { entryId, destinationId, order } = req.body; + const newRundown = await reorderEntry(entryId, destinationId, order); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay); router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry); diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index eb018d8af..6a396f21d 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -35,9 +35,9 @@ export const rundownBatchPutValidator = [ ]; export const rundownReorderValidator = [ - body('eventId').isString().exists(), - body('from').isNumeric().exists(), - body('to').isNumeric().exists(), + body('entryId').isString().exists(), + body('destinationId').isString().exists(), + body('order').isIn(['before', 'after', 'insert']).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 2b7b8e2f3..2514bac71 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -187,13 +187,14 @@ export async function batchEditEvents(ids: string[], data: Partial) /** * reorders a given entry - * @param {string} eventId - ID of event from, for sanity check - * @param {number} from - index of event from - * @param {number} to - index of event to */ -export async function reorderEntry(eventId: EntryId, from: number, to: number): Promise { +export async function reorderEntry( + entryId: EntryId, + destinationId: EntryId, + order: 'before' | 'after' | 'insert', +): Promise { const scopedMutation = cache.mutateCache(cache.reorder); - const { changeList, newRundown } = await scopedMutation({ eventId, from, to }); + const { changeList, newRundown } = await scopedMutation({ entryId, destinationId, order }); // notify runtime that rundown has changed updateRuntimeOnChange(); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index cc78148f9..840f922b0 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -783,31 +783,276 @@ describe('batchEdit() mutation', () => { }); describe('reorder() mutation', () => { - it('should correctly reorder two events', () => { + it('moves an event into a block', () => { const rundown = makeRundown({ order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }), - '2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }), - '3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }), + '1': makeOntimeBlock({ id: '1', events: [] }), + '2': makeOntimeEvent({ id: '2', parent: null }), + '3': makeOntimeEvent({ id: '3', parent: null }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '3', + destinationId: '1', + order: 'insert', + }); + + expect(newRundown.order).toStrictEqual(['1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '3', '2']); + // expect(changeList).toStrictEqual(['1', '3', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: ['3'], + }); + expect(rundown.entries['3']).toMatchObject({ + parent: '1', + }); + }); + + it('adds an event into a block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeEvent({ id: '2', parent: null }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '2', + destinationId: '11', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11']); + // expect(changeList).toStrictEqual(['1', '2', '11']); + expect(rundown.entries['1']).toMatchObject({ + events: ['2', '11'], + }); + expect(rundown.entries['2']).toMatchObject({ + parent: '1', + }); + }); + + it('moves an event after another', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), }, }); // move first event to the end - const { newRundown, changeList } = reorder({ + const { newRundown } = reorder({ rundown: rundown, - eventId: rundown.order[0], - from: 0, - to: rundown.order.length - 1, + entryId: '1', + destinationId: '2', + order: 'after', }); - expect(newRundown.order).toStrictEqual(['2', '3', '1']); - expect(newRundown.entries).toMatchObject({ - '2': { id: '2', cue: 'data2', revision: 1 }, - '3': { id: '3', cue: 'data3', revision: 1 }, - '1': { id: '1', cue: 'data1', revision: 1 }, + expect(newRundown.order).toStrictEqual(['2', '1', '3']); + // expect(newRundown.flatOrder).toStrictEqual(['2', '3', '1']); + // expect(changeList).toStrictEqual(['2', '3', '1']); + }); + + it('moves an event before another', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), + }, + }); + + // move last event to the beginning + const { newRundown } = reorder({ + rundown: rundown, + entryId: '3', + destinationId: '1', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['3', '1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['3', '1', '2']); + // expect(changeList).toStrictEqual(['3', '1', '2']); + }); + + it('moves an event out of a block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeEvent({ id: '2', parent: null }), + }, + }); + + const { newRundown, changeList } = reorder({ + rundown: rundown, + entryId: '11', + destinationId: '2', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1', '11', '2']); + expect(newRundown.flatOrder).toStrictEqual(['1', '11', '2']); + expect(changeList).toStrictEqual(['1', '11', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['2']).toMatchObject({ + parent: null, + }); + }); + + it('moves an event between blocks', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '11', + destinationId: '22', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['2']).toMatchObject({ + events: ['11', '22'], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: '2', + }); + }); + + it('moves an event into an empty block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: [] }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '22', + destinationId: '1', + order: 'insert', + }); + + expect(newRundown.order).toStrictEqual(['1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: ['22'], + }); + expect(rundown.entries['2']).toMatchObject({ + events: [], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: '1', + }); + }); + + it('moves an event out of a block (up)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '22', + destinationId: '2', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1', '22', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: ['11'], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: '1', + }); + expect(rundown.entries['2']).toMatchObject({ + events: [], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: null, + }); + }); + + it('moves an event out of a block (down)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '11', + destinationId: '1', + order: 'after', + }); + + expect(newRundown.order).toStrictEqual(['1', '11', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: null, + }); + expect(rundown.entries['2']).toMatchObject({ + events: ['22'], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: '2', }); - expect(changeList).toStrictEqual(['2', '3', '1']); }); }); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 439ee2fee..23f090ae0 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -13,14 +13,7 @@ import { RundownEntries, OntimeDelay, } from 'ontime-types'; -import { - generateId, - insertAtIndex, - reorderArray, - swapEventData, - customFieldLabelToKey, - mergeAtIndex, -} from 'ontime-utils'; +import { generateId, insertAtIndex, swapEventData, customFieldLabelToKey, mergeAtIndex } from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js'; @@ -518,31 +511,61 @@ export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): Mutating return { newRundown: rundown, didMutate: true }; } -type ReorderArgs = MutationParams<{ eventId: EntryId; from: number; to: number }>; +type ReorderArgs = MutationParams<{ + entryId: EntryId; + destinationId: EntryId; + order: 'before' | 'after' | 'insert'; +}>; /** - * Reorder two entries + * Moves an event to a new position in the rundown + * Handles moving across root orders (a block order and top level order) + * @throws if entryId or destinationId not found + * @throws if trying to insert an event into a block inside another block */ -export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required { - const eventFrom = rundown.entries[eventId]; - if (!eventFrom) { +export function reorder({ rundown, entryId, destinationId, order }: ReorderArgs): Required { + const eventFrom = rundown.entries[entryId]; + const eventTo = rundown.entries[destinationId]; + + if (!eventFrom || !eventTo) { throw new Error('Event not found'); } - rundown.order = reorderArray(rundown.order, from, to); - - // increment revision of all events in between - for (let i = from; i <= to; i++) { - const eventId = rundown.order[i]; - const entry = rundown.entries[eventId]; - if (isOntimeEvent(entry) || isOntimeBlock(entry)) { - entry.revision += 1; + const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null; + const toParent = (() => { + if (isOntimeBlock(eventTo)) { + if (order === 'insert') { + return eventTo.id; + } + return null; } + return eventTo.parent ?? null; + })(); + + if (!isOntimeBlock(eventFrom)) { + eventFrom.parent = toParent; } - // all events from the first one, need to be updated - const changeList = rundown.order.slice(Math.min(from, to), rundown.order.length); + const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events; + const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events; + + const fromIndex = sourceArray.indexOf(entryId); + const toIndex = (() => { + const baseIndex = destinationArray.indexOf(destinationId); + if (order === 'before') return baseIndex; + // only add one if we are moving down + if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1); + // for insert we add in the end of the array + return destinationArray.length; + })(); + + // Remove from source array + sourceArray.splice(fromIndex, 1); + // Insert into destination array + destinationArray.splice(toIndex, 0, entryId); + + // changelist is derived from the flat order + const changeList = rundown.flatOrder.slice(Math.min(fromIndex, toIndex), rundown.flatOrder.length); - setIsStale(); return { newRundown: rundown, changeList, newEvent: eventFrom, didMutate: true }; }