From 24a3823d3ba6454debb0685341c79df60ef6cc47 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 30 Jun 2025 07:16:08 +0200 Subject: [PATCH] refactor: entry actions in cuesheet fix: insert entry before fix: avoid double submit on enter fix: move events in the rundown fix: clone groups --- .../input/text-input/useReactiveTextInput.tsx | 28 ++- .../client/src/common/hooks/useEntryAction.ts | 128 ++++++----- .../src/common/hooks/useFollowComponent.ts | 50 +++- apps/client/src/features/rundown/Rundown.tsx | 24 +- .../rundown/__tests__/rundown.utils.test.ts | 213 ++++++++++++++---- .../src/features/rundown/rundown.utils.ts | 191 +++++++++------- .../cuesheet/cuesheet-table/CuesheetTable.tsx | 15 +- .../cuesheet-table-elements/BlockRow.tsx | 2 +- .../cuesheet-table-elements/CuesheetBody.tsx | 10 +- .../cuesheet-table-elements/EventRow.tsx | 27 +-- .../cuesheet-table-menu/CuesheetTableMenu.tsx | 45 ++-- .../useCuesheetTableMenu.tsx | 16 +- .../rundown/__tests__/rundown.dao.test.ts | 38 +++- .../rundown/__tests__/rundown.utils.test.ts | 46 +++- .../src/api-data/rundown/rundown.dao.ts | 25 +- .../src/api-data/rundown/rundown.service.ts | 8 +- .../src/api-data/rundown/rundown.utils.ts | 27 ++- 17 files changed, 592 insertions(+), 301 deletions(-) diff --git a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx index 91dd879a4..305458b50 100644 --- a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx +++ b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react'; +import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks'; interface UseReactiveTextInputReturn { @@ -21,6 +21,8 @@ export default function useReactiveTextInput( }, ): UseReactiveTextInputReturn { const [text, setText] = useState(initialText); + // track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur + const isKeyboardSubmitting = useRef(false); useEffect(() => { if (typeof initialText === 'undefined') { @@ -99,11 +101,25 @@ export default function useReactiveTextInput( ]; if (options?.submitOnEnter) { - hotKeys.push(['Enter', () => handleSubmit(text)]); + hotKeys.push(['Enter', () => { + isKeyboardSubmitting.current = true; + handleSubmit(text); + // clear flag after blur has been processed + setTimeout(() => { + isKeyboardSubmitting.current = false; + }, 0); + }]); } if (options?.submitOnCtrlEnter) { - hotKeys.push(['mod + Enter', () => handleSubmit(text)]); + hotKeys.push(['mod + Enter', () => { + isKeyboardSubmitting.current = true; + handleSubmit(text); + // clear flag after blur has been processed + setTimeout(() => { + isKeyboardSubmitting.current = false; + }, 0); + }]); } const hotKeyHandler = getHotkeyHandler(hotKeys); @@ -126,7 +142,11 @@ export default function useReactiveTextInput( return { value: text, onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value), - onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value), + onBlur: (event: ChangeEvent) => { + if (!isKeyboardSubmitting.current) { + handleSubmit((event.target as HTMLInputElement).value); + } + }, onKeyDown: keyHandler, }; } diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 06e7a18a2..be86d12c6 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -74,7 +74,7 @@ export const useEntryActions = () => { * Calls mutation to add new entry * @private */ - const _addEntryMutation = useMutation({ + const { mutateAsync: addEntryMutation } = useMutation({ // TODO(v4): optimistic create entry mutationFn: postAddEntry, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), @@ -143,13 +143,13 @@ export const useEntryActions = () => { } try { - await _addEntryMutation.mutateAsync(newEntry); + await addEntryMutation(newEntry); } catch (error) { logAxiosError('Failed adding event', error); } }, [ - _addEntryMutation, + addEntryMutation, defaultDangerTime, defaultDuration, defaultEndAction, @@ -165,7 +165,7 @@ export const useEntryActions = () => { * Calls mutation to clone a selection * @private */ - const _cloneMutation = useMutation({ + const { mutateAsync: cloneEntryMutation } = useMutation({ mutationFn: postCloneEntry, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); @@ -176,19 +176,19 @@ export const useEntryActions = () => { const clone = useCallback( async (entryId: EntryId) => { try { - await _cloneMutation.mutateAsync(entryId); + await cloneEntryMutation(entryId); } catch (error) { logAxiosError('Error cloning entry', error); } }, - [_cloneMutation], + [cloneEntryMutation], ); /** * Calls mutation to update existing entry * @private */ - const _updateEntryMutation = useMutation({ + const { mutateAsync: updateEntryMutation } = useMutation({ mutationFn: putEditEntry, // we optimistically update here onMutate: async (newEvent) => { @@ -234,12 +234,12 @@ export const useEntryActions = () => { const updateEntry = useCallback( async (event: Partial) => { try { - await _updateEntryMutation.mutateAsync(event); + await updateEntryMutation(event); } catch (error) { logAxiosError('Error updating event', error); } }, - [_updateEntryMutation], + [updateEntryMutation], ); const updateCustomField = useCallback( @@ -287,7 +287,7 @@ export const useEntryActions = () => { } try { - await _updateEntryMutation.mutateAsync(newEvent); + await updateEntryMutation(newEvent); } catch (error) { logAxiosError('Error updating event', error); } @@ -339,14 +339,14 @@ export const useEntryActions = () => { return previousEnd; } }, - [_updateEntryMutation, queryClient], + [updateEntryMutation, queryClient], ); /** * Calls mutation to edit multiple events * @private */ - const _batchUpdateEventsMutation = useMutation({ + const { mutateAsync: batchUpdateEventsMutation } = useMutation({ mutationFn: putBatchEditEvents, onMutate: async ({ ids, data }) => { // cancel ongoing queries @@ -405,19 +405,19 @@ export const useEntryActions = () => { const batchUpdateEvents = useCallback( async (data: Partial, eventIds: string[]) => { try { - await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data }); + await batchUpdateEventsMutation({ ids: eventIds, data }); } catch (error) { logAxiosError('Error updating events', error); } }, - [_batchUpdateEventsMutation], + [batchUpdateEventsMutation], ); /** * Calls mutation to delete an entry * @private */ - const _deleteEntryMutation = useMutation({ + const { mutateAsync: deleteEntryMutation } = useMutation({ mutationFn: deleteEntries, // we optimistically update here onMutate: async (entryIds: EntryId[]) => { @@ -462,19 +462,19 @@ export const useEntryActions = () => { const deleteEntry = useCallback( async (entryIds: EntryId[]) => { try { - await _deleteEntryMutation.mutateAsync(entryIds); + await deleteEntryMutation(entryIds); } catch (error) { logAxiosError('Error deleting event', error); } }, - [_deleteEntryMutation], + [deleteEntryMutation], ); /** * Calls mutation to delete all events * @private */ - const _deleteAllEntriesMutation = useMutation({ + const { mutateAsync: deleteAllEntriesMutation } = useMutation({ mutationFn: requestDeleteAll, // we optimistically update here onMutate: async () => { @@ -514,17 +514,17 @@ export const useEntryActions = () => { */ const deleteAllEntries = useCallback(async () => { try { - await _deleteAllEntriesMutation.mutateAsync(); + await deleteAllEntriesMutation(); } catch (error) { logAxiosError('Error deleting events', error); } - }, [_deleteAllEntriesMutation]); + }, [deleteAllEntriesMutation]); /** * Calls mutation to apply a delay * @private */ - const _applyDelayMutation = useMutation({ + const { mutateAsync: applyDelayMutation } = useMutation({ mutationFn: requestApplyDelay, onSuccess: (response) => { if (!response.data) return; @@ -551,19 +551,19 @@ export const useEntryActions = () => { const applyDelay = useCallback( async (delayEventId: EntryId) => { try { - await _applyDelayMutation.mutateAsync(delayEventId); + await applyDelayMutation(delayEventId); } catch (error) { logAxiosError('Error applying delay', error); } }, - [_applyDelayMutation], + [applyDelayMutation], ); /** * Calls mutation to dissolve a block * @private */ - const _ungroupMutation = useMutation({ + const { mutateAsync: ungroupMutation } = useMutation({ mutationFn: requestUngroup, onSuccess: (response) => { if (!response.data) return; @@ -587,19 +587,19 @@ export const useEntryActions = () => { const ungroup = useCallback( async (blockId: EntryId) => { try { - await _ungroupMutation.mutateAsync(blockId); + await ungroupMutation(blockId); } catch (error) { logAxiosError('Error dissolving block', error); } }, - [_ungroupMutation], + [ungroupMutation], ); /** * Calls mutation to create a block with a selection * @private */ - const _groupEntriesMutation = useMutation({ + const { mutateAsync: groupEntriesMutation } = useMutation({ mutationFn: requestGroupEntries, onSuccess: (response) => { if (!response.data) return; @@ -623,19 +623,19 @@ export const useEntryActions = () => { const groupEntries = useCallback( async (entryIds: EntryId[]) => { try { - await _groupEntriesMutation.mutateAsync(entryIds); + await groupEntriesMutation(entryIds); } catch (error) { logAxiosError('Error grouping entries', error); } }, - [_groupEntriesMutation], + [groupEntriesMutation], ); /** * Calls mutation to reorder an entry * @private */ - const _reorderEntryMutation = useMutation({ + const { mutateAsync: reorderEntryMutation } = useMutation({ mutationFn: patchReorderEntry, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -644,6 +644,40 @@ export const useEntryActions = () => { }, }); + /** + * Reorders a given entry one step up or down in the timeline + */ + const move = useCallback( + async (entryId: EntryId, direction: 'up' | 'down') => { + const rundown = queryClient.getQueryData(RUNDOWN); + if (!rundown) { + return; + } + + const { destinationId, order } = + direction === 'up' + ? moveUp(entryId, rundown.flatOrder, rundown.entries) + : moveDown(entryId, rundown.flatOrder, rundown.entries); + + if (!destinationId) { + return; // noop + } + + try { + const reorderObject: ReorderEntry = { + entryId, + destinationId, + order, + }; + await reorderEntryMutation(reorderObject); + } catch (error) { + logAxiosError('Error re-ordering event', error); + } + // the rundown needs to know whether we moved into a block + return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined; + }, + [queryClient, reorderEntryMutation], + ); /** * Reorders a given entry */ @@ -655,43 +689,19 @@ export const useEntryActions = () => { destinationId, order, }; - await _reorderEntryMutation.mutateAsync(reorderObject); + await reorderEntryMutation(reorderObject); } catch (error) { logAxiosError('Error re-ordering event', error); } }, - [_reorderEntryMutation], + [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 */ - const _swapEvents = useMutation({ + const { mutateAsync: swapEventsMutation } = useMutation({ mutationFn: requestEventSwap, // we optimistically update here onMutate: async ({ from, to }) => { @@ -745,12 +755,12 @@ export const useEntryActions = () => { const swapEvents = useCallback( async ({ from, to }: SwapEntry) => { try { - await _swapEvents.mutateAsync({ from, to }); + await swapEventsMutation({ from, to }); } catch (error) { logAxiosError('Error re-ordering event', error); } }, - [_swapEvents], + [swapEventsMutation], ); return { diff --git a/apps/client/src/common/hooks/useFollowComponent.ts b/apps/client/src/common/hooks/useFollowComponent.ts index cd6660251..bb1c1a85d 100644 --- a/apps/client/src/common/hooks/useFollowComponent.ts +++ b/apps/client/src/common/hooks/useFollowComponent.ts @@ -1,4 +1,6 @@ -import { MutableRefObject, useCallback, useEffect } from 'react'; +import { MutableRefObject, useCallback, useEffect, useRef } from 'react'; + +import { useSelectedEventId } from './useSocket'; function scrollToComponent( componentRef: MutableRefObject, @@ -16,6 +18,23 @@ function scrollToComponent( + componentRef: MutableRefObject, + scrollRef: MutableRefObject, + topOffset: number, +) { + if (!componentRef.current || !scrollRef.current) { + return; + } + + const componentRect = componentRef.current.getBoundingClientRect(); + const scrollRect = scrollRef.current.getBoundingClientRect(); + const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset; + + // maintain current x scroll position + scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top); +} + interface UseFollowComponentProps { followRef: MutableRefObject; scrollRef: MutableRefObject; @@ -62,3 +81,32 @@ export default function useFollowComponent(props: UseFollowComponentProps) { return scrollToRefComponent; } + +export function useFollowSelected(doFollow: boolean, topOffset = 100) { + const selectedEvenId = useSelectedEventId(); + + const selectedRef = useRef(null); + const scrollRef = useRef(null); + + useEffect(() => { + if (!doFollow) { + return; + } + + if (selectedEvenId && selectedRef.current && scrollRef.current) { + // Use requestAnimationFrame to ensure the component is fully loaded + window.requestAnimationFrame(() => { + snapToComponent( + { current: selectedRef.current } as MutableRefObject, + { current: scrollRef.current } as MutableRefObject, + topOffset, + ); + }); + } + }, [doFollow, selectedEvenId, topOffset]); + + return { + selectedRef, + scrollRef, + }; +} diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 430ff2058..bff28fcf8 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 QuickAddBlock from './quick-add-block/QuickAddBlock'; import BlockEnd from './rundown-block/BlockEnd'; import RundownBlock from './rundown-block/RundownBlock'; -import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils'; +import { makeRundownMetadata, makeSortableList } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -65,7 +65,7 @@ export default function Rundown({ data }: RundownProps) { defaultValue: [], }); - const { addEntry, reorderEntry, deleteEntry } = useEntryActions(); + const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions(); const { entryCopyId, setEntryCopyId } = useEntryCopy(); @@ -218,26 +218,18 @@ export default function Rundown({ data }: RundownProps) { ); const moveEntry = useCallback( - (cursor: EntryId | null, direction: 'up' | 'down') => { - if (sortableData.length < 2 || cursor == null) { - return; - } - - const { destinationId, order, isBlock } = - direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries); - - if (!destinationId) { + async (cursor: EntryId | null, direction: 'up' | 'down') => { + if (cursor == null) { return; } + const movedIntoBlockId = await move(cursor, direction); // if we are moving into a block, we need to make sure it is expanded - if (isBlock) { - handleCollapseGroup(false, destinationId); + if (movedIntoBlockId) { + handleCollapseGroup(false, movedIntoBlockId); } - - reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert'); }, - [sortableData, entries, reorderEntry, handleCollapseGroup], + [handleCollapseGroup, move], ); // shortcuts 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 eec21b602..ca54128d8 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -334,66 +334,199 @@ describe('makeSortableList()', () => { }); }); + 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', entries: ['event3'] } as OntimeBlock, - event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent, - block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock, - event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent, + const rundown = { + entries: { + '1': { id: '1', type: 'event', parent: null } as OntimeEvent, + '2': { id: '2', type: 'event', parent: null } as OntimeEvent, + '3': { id: '3', type: 'event', parent: null } as OntimeEvent, + block: { id: 'block', type: 'block', entries: ['11', '12'] } as OntimeBlock, + '11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent, + '12': { id: '12', type: 'event', parent: 'block' } as OntimeEvent, + '4': { id: '4', type: 'event', parent: null } as OntimeEvent, + block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock, + '5': { id: '5', type: 'event', parent: null } as OntimeEvent, + }, + order: ['1', '2', '3', 'block', '4', 'block2', '5'], + flatOrder: ['1', '2', '3', 'block', '11', '12', '4', 'block2', '5'], }; - it('moves an event up in the list', () => { - const result = moveUp('event2', sortableData, entries); - expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false }); + it('moving the first event is a noop', () => { + expect(moveUp('1', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: null, + order: 'before', + }); }); - it.todo('disallows nesting blocks', () => { - const result = moveUp('block2', sortableData, entries); - expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false }); + it('moves an entry up in the rundown', () => { + expect(moveUp('2', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '1', + order: 'before', + }); }); - it('moves an event into a block', () => { - const result = moveUp('event3', sortableData, entries); - expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true }); + it('moves an entry up inside a block', () => { + expect(moveUp('12', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '11', + order: 'before', + }); }); - it('moving up from top is noop', () => { - const result = moveUp('event1', sortableData, entries); - expect(result).toMatchObject({ destinationId: null }); + it('moves an entry up into an empty group', () => { + expect(moveUp('5', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block2', + order: 'insert', + }); + }); + + it('moves an entry up into a group', () => { + expect(moveUp('4', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '12', + order: 'after', + }); + }); + + it('moves an entry up out of a group', () => { + expect(moveUp('11', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block', + order: 'before', + }); + }); + + it('moves a block in the rundown', () => { + expect(moveUp('block', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '3', + order: 'before', + }); + }); + + it('swaps two blocks', () => { + const rundown = { + entries: { + block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock, + '11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent, + block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock, + }, + order: ['block', 'block2'], + flatOrder: ['block', '11', 'block2'], + }; + expect(moveUp('block2', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block', + order: 'before', + }); + }); + + it('moves before a block', () => { + const rundown = { + entries: { + block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock, + '11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent, + }, + order: ['block'], + flatOrder: ['block', '11'], + }; + expect(moveUp('11', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block', + order: 'before', + }); }); }); 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', entries: ['event11'] } as OntimeBlock, - event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent, - block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock, - event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent, + const rundown = { + entries: { + '1': { id: '1', type: 'event', parent: null } as OntimeEvent, + '2': { id: '2', type: 'event', parent: null } as OntimeEvent, + '3': { id: '3', type: 'event', parent: null } as OntimeEvent, + block: { id: 'block', type: 'block', entries: ['11', '12'] } as OntimeBlock, + '11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent, + '12': { id: '12', type: 'event', parent: 'block' } as OntimeEvent, + '4': { id: '4', type: 'event', parent: null } as OntimeEvent, + block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock, + '5': { id: '5', type: 'event', parent: null } as OntimeEvent, + }, + order: ['1', '2', '3', 'block', '4', 'block2', '5'], + flatOrder: ['1', '2', '3', 'block', '11', '12', '4', 'block2', '5'], }; - it('moves an event down in the list', () => { - const result = moveDown('event1', sortableData, entries); - expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false }); + it('moving the last event is a noop', () => { + expect(moveDown('5', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: null, + order: 'after', + }); }); - it.todo('disallows nesting blocks', () => { - const result = moveDown('block1', sortableData, entries); - expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false }); + it('moves an entry down in the rundown', () => { + expect(moveDown('2', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '3', + order: 'after', + }); }); - it('moves an event into a block', () => { - const result = moveDown('event2', sortableData, entries); - expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true }); + it('moves an entry down inside a block', () => { + expect(moveDown('11', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '12', + order: 'after', + }); }); - it('moving down from bottom is noop', () => { - const result = moveDown('event3', sortableData, entries); - expect(result).toMatchObject({ destinationId: null }); + it('moves an entry down into an empty group', () => { + expect(moveDown('4', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block2', + order: 'insert', + }); + }); + + it('moves an entry down out of a group', () => { + expect(moveDown('12', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '4', + order: 'after', + }); + }); + + it('moves an entry down into a group', () => { + expect(moveDown('3', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '11', + order: 'before', + }); + }); + + it('moves a block in the rundown', () => { + expect(moveDown('block', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: '4', + order: 'after', + }); + }); + + it('swaps two blocks', () => { + const rundown = { + entries: { + block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock, + '11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent, + block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock, + }, + order: ['block', 'block2'], + flatOrder: ['block', '11', 'block2'], + }; + expect(moveDown('block', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block2', + order: 'after', + }); + }); + + it('moves after a block', () => { + const rundown = { + entries: { + block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock, + '11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent, + }, + order: ['block'], + flatOrder: ['block', '11'], + }; + expect(moveDown('11', rundown.flatOrder, rundown.entries)).toStrictEqual({ + destinationId: 'block', + order: 'after', + }); }); }); diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index 75e83883b..e704b4518 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -165,101 +165,136 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu } /** - * 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 + * calculates destinations for an entry moving one position up in the rundown + * @returns An object describing how to move the entry: + * - destinationId: The target entry ID (null if no movement possible) + * - order: How to position relative to the destination: + * - 'before': Place before the destination + * - 'after': Place after the destination + * - 'insert': Insert into the destination (for blocks) */ -export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) { - const previousEntryId = getPreviousId(entryId, sortableData); +export function moveUp( + entryId: EntryId, + flatOrder: EntryId[], + entries: RundownEntries, +): { destinationId: EntryId | null; order: 'before' | 'after' | 'insert' } { + const currentEntry = entries[entryId]; + const currentIndex = flatOrder.indexOf(entryId); + const previousEntryId = flatOrder[currentIndex - 1]; - // the user is moving up at the top of the list + // 1. moving 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 }; + // 1a. we are in a block and need to move outside of it + if ('parent' in currentEntry && currentEntry.parent !== null) { + return { destinationId: currentEntry.parent, order: 'before' }; } - // insert in the block ID will add to the end of the block events - return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true }; + // 1b. we are at the start of the rundown, no movement possible + return { destinationId: null, order: 'before' }; } - // @ts-expect-error -- we safeguard the entry not having a parent property - return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) }; + // 2. moving a block (always moves at top level) + if (isOntimeBlock(currentEntry)) { + // 21. if previous entry is inside a block, swap with parent + const previousEntry = entries[previousEntryId]; + if ('parent' in previousEntry && previousEntry.parent !== null) { + return { destinationId: previousEntry.parent, order: 'before' }; + } + + // 2b. previous entry is at top level, we just swap places + return { destinationId: previousEntryId, order: 'before' }; + } + + const previousEntry = entries[previousEntryId]; + const currentEntryParent = currentEntry.parent; + + // 3. moving in and out of a block + if (isOntimeBlock(previousEntry)) { + // 3a. if we're not already in the block, move into it + if (currentEntryParent === null) { + return { destinationId: previousEntryId, order: 'insert' }; + } + // 3b. otherwise, move before the block + return { destinationId: previousEntryId, order: 'before' }; + } + + // 4. moving into the same block as previous entry + if (isOntimeEvent(previousEntry) && previousEntry.parent !== null && currentEntryParent === null) { + return { destinationId: previousEntryId, order: 'after' }; + } + + // default - swap positions with previous entry + return { destinationId: previousEntryId, order: 'before' }; } /** - * 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 + * calculates destinations for an entry moving one position down in the rundown + * @returns An object describing how to move the entry: + * - destinationId: The target entry ID (null if no movement possible) + * - order: How to position relative to the destination: + * - 'before': Place before the destination + * - 'after': Place after the destination + * - 'insert': Insert into the destination (for blocks) */ -export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) { - const nextEntryId = getNextId(entryId, sortableData); +export function moveDown( + entryId: EntryId, + flatOrder: EntryId[], + entries: RundownEntries, +): { destinationId: EntryId | null; order: 'before' | 'after' | 'insert' } { + const currentEntry = entries[entryId]; + const currentIndex = flatOrder.indexOf(entryId); + const nextEntryId = flatOrder[currentIndex + 1]; - // the user is moving down at the end of the list + // 1. moving at the top 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 }; + // 1a. we are in a block and need to move outside of it + if ('parent' in currentEntry && currentEntry.parent !== null) { + return { destinationId: currentEntry.parent, order: 'after' }; } + // 1b. we are at the end of the rundown, no movement possible + return { destinationId: null, order: 'after' }; + } - const firstBlockChild = entries[nextEntryId].entries.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 }; + // 2. moving a block (always moves at top level) + if (isOntimeBlock(currentEntry)) { + // if next entry is inside this block, skip past all children + if (currentEntry.entries.includes(nextEntryId)) { + const afterBlockIndex = currentIndex + currentEntry.entries.length + 1; + const afterBlockId = flatOrder[afterBlockIndex]; + + // 2a. block is the last top level entry + if (!afterBlockId) { + return { destinationId: null, order: 'after' }; + } + // 2b. move after the next top level event + return { destinationId: afterBlockId, order: 'after' }; + } + // 2c. empty block move after the next entry + return { destinationId: nextEntryId, order: 'after' }; + } + + const nextEntry = entries[nextEntryId]; + const currentEntryParent = currentEntry.parent; + + // 3. handle moving relative to blocks + if (isOntimeBlock(nextEntry)) { + if (currentEntryParent === null) { + // we are entering a block + if (nextEntry.entries.length === 0) { + // 3a. if the block is empty, insert into it + return { destinationId: nextEntryId, order: 'insert' }; + } + // 3b. otherwise, add before the first entry in the block + const firstBlockEntryId = nextEntry.entries[0]; + return { destinationId: firstBlockEntryId, order: 'before' }; } } - 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 - return null; + // 4. handle moving between block and top level + const nextEntryParent = isOntimeEvent(nextEntry) ? nextEntry.parent : null; + if (nextEntryParent !== null && currentEntryParent === null) { + return { destinationId: nextEntryId, order: 'after' }; } - return sortableData[currentIndex + 1]; -} -/** - * 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 - return null; - } - return sortableData[currentIndex - 1]; + // default - swap positions with next entry + return { destinationId: nextEntryId, order: 'after' }; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index 47a03d9fd..24fd19b8a 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -1,10 +1,10 @@ -import { memo, useCallback, useMemo, useRef } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTableNav } from '@table-nav/react'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { OntimeEntry, TimeField } from 'ontime-types'; import { useEntryActions } from '../../../common/hooks/useEntryAction'; -import useFollowComponent from '../../../common/hooks/useFollowComponent'; +import { useFollowSelected } from '../../../common/hooks/useFollowComponent'; import { usePersistedCuesheetOptions } from '../cuesheet.options'; import CuesheetBody from './cuesheet-table-elements/CuesheetBody'; @@ -26,9 +26,7 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) { const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes); const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds); - const selectedRef = useRef(null); - const tableContainerRef = useRef(null); - useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followPlayback }); + const { selectedRef, scrollRef } = useFollowSelected(followPlayback); const { listeners } = useTableNav(); @@ -106,12 +104,13 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; for (let i = 0; i < headers.length; i++) { - const header = headers[i]!; + const header = headers[i]; + if (!header) continue; colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } return colSizes; - }, [table.getState().columnSizingInfo, table.getState().columnSizing]); + }, [table]); return ( <> @@ -121,7 +120,7 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) { handleResetReordering={resetColumnOrder} handleClearToggles={setAllVisible} /> -
+
{table.getState().columnSizingInfo.isResizingColumn ? ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx index 6af1524d6..9f850946c 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx @@ -40,7 +40,7 @@ export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, t onClick={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; - openMenu({ x: rect.x, y: yPos }, blockId, rowIndex); + openMenu({ x: rect.x, y: yPos }, blockId, rowIndex, null); }} > diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx index 3148ce6e3..a22721101 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx @@ -1,4 +1,4 @@ -import { MutableRefObject, useMemo } from 'react'; +import { RefObject, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { RowModel, Table } from '@tanstack/react-table'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types'; @@ -17,7 +17,7 @@ import { useVisibleRowsStore } from './visibleRowsStore'; interface CuesheetBodyProps { rowModel: RowModel; - selectedRef: MutableRefObject; + selectedRef: RefObject; table: Table; } @@ -106,7 +106,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB const isSelected = key === selectedEventId; const columnHash = getColumnHash(); - if (isPast && hidePast) { return null; } @@ -129,14 +128,13 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB let firstAfterBlock = false; if (entry.parent) { const rundown = queryClient.getQueryData(RUNDOWN); - const parentEntry = rundown?.entries[entry.parent]; - parentBgColour = (parentEntry as OntimeBlock).colour; + const parentEntry = rundown?.entries[entry.parent] as OntimeBlock | undefined; + parentBgColour = parentEntry?.colour; hadBlock = true; } else if (hadBlock) { firstAfterBlock = true; hadBlock = false; } - return ( { - return ( - prevProps.rowId === nextProps.rowId && - prevProps.event.revision === nextProps.event.revision && - prevProps.eventIndex === nextProps.eventIndex && - prevProps.rowIndex === nextProps.rowIndex && - prevProps.isPast === nextProps.isPast && - prevProps.selectedRef === nextProps.selectedRef && - prevProps.rowBgColour === nextProps.rowBgColour && - prevProps.parentBgColour === nextProps.parentBgColour && - prevProps.columnHash === nextProps.columnHash - ); -}); - -function EventRow({ +export default function EventRow({ rowId, event, eventIndex, @@ -87,7 +73,12 @@ function EventRow({ return ( { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; - openMenu({ x: rect.x, y: yPos }, event.id, rowIndex); + openMenu({ x: rect.x, y: yPos }, event.id, rowIndex, event.parent); }} > diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx index 24e564d01..f4e6a801a 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx @@ -1,10 +1,9 @@ import { memo } from 'react'; import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5'; import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react'; -import { isOntimeEvent, SupportedEntry } from 'ontime-types'; +import { SupportedEntry } from 'ontime-types'; import { useEntryActions } from '../../../../common/hooks/useEntryAction'; -import { cloneEvent } from '../../../../common/utils/clone'; import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; import { useCuesheetTableMenu } from './useCuesheetTableMenu'; @@ -12,28 +11,10 @@ import { useCuesheetTableMenu } from './useCuesheetTableMenu'; export default memo(CuesheetTableMenu); function CuesheetTableMenu() { - const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu(); - const { addEntry, getEntryById, move, deleteEntry } = useEntryActions(); + const { isOpen, entryId, entryIndex, parentId, position, closeMenu } = useCuesheetTableMenu(); + const { addEntry, clone, deleteEntry, move } = useEntryActions(); const showModal = useCuesheetEditModal((state) => state.setEditableEntry); - const handleCloneEvent = () => { - if (!eventId) { - return; - } - - const currentEvent = getEntryById(eventId); - if (!currentEvent || !isOntimeEvent(currentEvent)) { - return; - } - - const newEvent = cloneEvent(currentEvent); - try { - addEntry(newEvent, { after: eventId }); - } catch (_error) { - // we do not handle errors here - } - }; - return ( {isOpen && ( @@ -48,27 +29,33 @@ function CuesheetTableMenu() { h={1} /> - } onClick={() => showModal(eventId)}> + } onClick={() => showModal(entryId)}> Edit ... - } onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}> + } + onClick={() => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId })} + > Add event above - } onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}> + } + onClick={() => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId })} + > Add event below - } onClick={handleCloneEvent}> + } onClick={() => clone(entryId)}> Clone event - } onClick={() => move(eventId, 'up')}> + } onClick={() => move(entryId, 'up')}> Move up - } onClick={() => move(eventId, 'down')}> + } onClick={() => move(entryId, 'down')}> Move down - } onClick={() => deleteEntry([eventId])}> + } onClick={() => deleteEntry([entryId])}> Delete diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx index b531399b6..d0eb483c2 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx @@ -1,31 +1,35 @@ +import { EntryId } from 'ontime-types'; import { create } from 'zustand'; type Anchor = { x: number; y: number }; type OpenMenu = { isOpen: true; - eventId: string; + entryId: EntryId; entryIndex: number; + parentId: EntryId | null; }; type ClosedMenu = { isOpen: false; - eventId: null; + entryId: null; entryIndex: null; + parentId: null; }; type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & { position: Anchor; - openMenu: (position: Anchor, eventId: string, entryIndex: number) => void; + openMenu: (position: Anchor, entryId: EntryId, entryIndex: number, parentId: EntryId | null) => void; closeMenu: () => void; }; export const useCuesheetTableMenu = create((set) => ({ isOpen: false, - eventId: null, + entryId: null, entryIndex: null, + parentId: null, position: { x: 0, y: 0 }, - openMenu: (position: Anchor, eventId: string, entryIndex: number) => - set({ isOpen: true, position, eventId, entryIndex }), + openMenu: (position: Anchor, entryId: EntryId, entryIndex: number, parentId: EntryId | null) => + set({ isOpen: true, position, entryId, entryIndex, parentId }), closeMenu: () => set({ isOpen: false }), })); diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts index d800de298..c75befd09 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts @@ -699,7 +699,7 @@ describe('rundownMutation.add()', () => { }, }); - rundownMutation.add(rundown, mockEvent, null, '1'); + rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeBlock); expect(rundown.order).toStrictEqual(['1']); expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']); @@ -717,7 +717,7 @@ describe('rundownMutation.add()', () => { }, }); - rundownMutation.add(rundown, mockEvent, '1a', '1'); + rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeBlock); expect(rundown.order).toStrictEqual(['1']); expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); @@ -1058,6 +1058,40 @@ describe('rundownMutation.reorder()', () => { parent: '2', }); }); + + it('moves a block (up)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', entries: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', entries: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['2'], rundown.entries['1'], 'before'); + + expect(rundown.order).toStrictEqual(['2', '1']); + }); + + it('moves a block (down)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', entries: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', entries: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['1'], rundown.entries['2'], 'after'); + + expect(rundown.order).toStrictEqual(['2', '1']); + }); }); describe('rundownMutation.applyDelay()', () => { diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index c0b956f70..43adb62fd 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -1,10 +1,17 @@ -import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types'; +import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeBlock } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { assertType } from 'vitest'; -import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js'; -import { makeRundown } from '../__mocks__/rundown.mocks.js'; +import { + calculateDayOffset, + createEvent, + deleteById, + doesInvalidateMetadata, + getInsertAfterId, + hasChanges, +} from '../rundown.utils.js'; +import { makeOntimeBlock, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; describe('test event validator', () => { it('validates a good object', () => { @@ -217,22 +224,39 @@ describe('calculateDayOffset()', () => { describe('getInsertAfterId()', () => { const rundown = makeRundown({ - flatOrder: ['a', 'b', 'c', 'd'], + entries: { + '1': makeOntimeEvent({ id: '1', parent: null }), + '2': makeOntimeEvent({ id: '2', parent: null }), + block: makeOntimeBlock({ id: 'block', entries: ['31', '32'] }), + '31': makeOntimeEvent({ id: '31', parent: 'block' }), + '32': makeOntimeEvent({ id: '32', parent: 'block' }), + '4': makeOntimeEvent({ id: '31', parent: null }), + }, + order: ['1', '2', 'block', '4'], + flatOrder: ['1', '2', 'block', '31', '32', '4'], }); it('returns afterId if provided', () => { - expect(getInsertAfterId(rundown, 'b')).toBe('b'); + expect(getInsertAfterId(rundown, null, 'b')).toBe('b'); }); - it('returns the previous id before beforeId if provided', () => { - expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b'); + it('returns null if neither afterId nor beforeId is provided', () => { + expect(getInsertAfterId(rundown, null)).toBeNull(); }); - it('returns undefined if neither afterId nor beforeId is provided', () => { - expect(getInsertAfterId(rundown)).toBeNull(); + it('returns null if beforeId is not found', () => { + expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull(); + expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull(); }); - it('returns undefined if beforeId is not found', () => { - expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull(); + it('returns the previous id of an entry in the rundown', () => { + expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1'); + expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('block'); + expect(getInsertAfterId(rundown, null, undefined, 'block')).toBe('2'); + }); + + it('returns the previous id of an event in a block', () => { + expect(getInsertAfterId(rundown, rundown.entries.block as OntimeBlock, undefined, '31')).toBeNull(); + expect(getInsertAfterId(rundown, rundown.entries.block as OntimeBlock, undefined, '32')).toBe('31'); }); }); diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 91032718a..a7209247f 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -189,18 +189,17 @@ export function createTransaction(options: TransactionOptions): Transaction { * - 2a. add entry to the rundown, after a given entry * - 2b. add entry to the rundown, at the beginning */ -function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parentId: EntryId | null): OntimeEntry { - if (parentId) { +function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeBlock | null): OntimeEntry { + if (parent) { // 1. inserting an entry inside a block - const parentBlock = rundown.entries[parentId] as OntimeBlock; if (afterId) { - const atEventsIndex = parentBlock.entries.indexOf(afterId) + 1; + const atEventsIndex = parent.entries.indexOf(afterId) + 1; const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; - parentBlock.entries = insertAtIndex(atEventsIndex, entry.id, parentBlock.entries); + parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries); rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); } else { - parentBlock.entries = insertAtIndex(0, entry.id, parentBlock.entries); - const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1; + parent.entries = insertAtIndex(0, entry.id, parent.entries); + const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1; rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); } } else { @@ -282,6 +281,8 @@ function removeAll(rundown: Rundown): Rundown { /** * Reorders an entry in the rundown * Handle moving across order lists + * @param order - 'before' | 'after' | 'insert' - where to add the entry, insert serves to add the entry into an empty block + * @throws if we insert a block inside another */ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, order: 'before' | 'after' | 'insert') { // handle moving across parents @@ -289,6 +290,10 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, const toParent = (() => { if (isOntimeBlock(eventTo)) { if (order === 'insert') { + // prevent blocks from being inserted into other blocks + if (isOntimeBlock(eventFrom)) { + throw new Error('Cannot insert a block into another block'); + } return eventTo.id; } return null; @@ -296,7 +301,8 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, return eventTo.parent ?? null; })(); - if (!isOntimeBlock(eventFrom)) { + // always update the parent when moving entries + if ('parent' in eventFrom) { eventFrom.parent = toParent; } @@ -469,7 +475,8 @@ function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry { return newBlock; } else { - return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, entry.parent); + const parent: OntimeBlock | null = entry.parent ? (rundown.entries[entry.parent] as OntimeBlock) : null; + return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, parent); } } diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 4a8e986a4..3872a8b2b 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -7,6 +7,7 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, + OntimeBlock, OntimeEntry, OntimeEvent, PatchWithId, @@ -35,23 +36,24 @@ export async function addEntry(eventData: EventPostPayload): Promise id === beforeId); - if (atIndex < 1) return null; - return rundown.flatOrder[atIndex - 1]; - } + /** + * At this point we know we want to insert before a given ID + * We need to check which list we should use to insert and find the event there + */ + const insertionList = parent ? parent.entries : rundown.order; + if (!insertionList || insertionList.length === 0) return null; - return null; + const atIndex = insertionList.findIndex((id) => id === beforeId); + if (atIndex < 1) return null; + return insertionList[atIndex - 1]; } /**