diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 8b78efc3d..16ca95dea 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -25,6 +25,7 @@ import { ReorderEntry, requestApplyDelay, requestDeleteAll, + requestDissolveBlock, requestEventSwap, SwapEntry, } from '../api/rundown'; @@ -514,6 +515,28 @@ export const useEntryActions = () => { [_applyDelayMutation], ); + /** + * Calls mutation to dissolve a block + * @private + */ + const _dissolveBlockMutation = useMutation({ + mutationFn: requestDissolveBlock, + onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + }); + + /** + * Deletes a block and moves its events to the top level + */ + const dissolveBlock = useCallback( + async (blockId: EntryId) => { + try { + await _dissolveBlockMutation.mutateAsync(blockId); + } catch (error) { + logAxiosError('Error dissolving block', error); + } + }, + [_dissolveBlockMutation], + ); /** * Calls mutation to reorder an entry * @private @@ -663,6 +686,7 @@ export const useEntryActions = () => { batchUpdateEvents, deleteEntry, deleteAllEntries, + dissolveBlock, getEntryById, reorderEntry, swapEvents, diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index d2c9ef88b..3e88d4907 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,9 +1,11 @@ import { useRef } from 'react'; -import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5'; +import { IoChevronDown, IoChevronUp, IoEllipsisHorizontal, IoReorderTwo } from 'react-icons/io5'; +import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { EntryId, OntimeBlock } from 'ontime-types'; +import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { formatDuration, formatTime } from '../../../common/utils/time'; import EditableBlockTitle from '../common/EditableBlockTitle'; @@ -21,6 +23,7 @@ interface BlockBlockProps { export default function BlockBlock(props: BlockBlockProps) { const { data, hasCursor, collapsed, onCollapse } = props; const handleRef = useRef(null); + const { dissolveBlock } = useEntryActions(); const { attributes: dragAttributes, @@ -71,9 +74,28 @@ export default function BlockBlock(props: BlockBlockProps) {
- +
diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index d484bb0ea..2a3f6d59b 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -11,6 +11,7 @@ import { deleteAllEntries, deleteEvent, editEvent, + dissolveBlock, reorderEntry, swapEvents, } from '../../services/rundown-service/RundownService.js'; @@ -126,6 +127,16 @@ export async function rundownApplyDelay(req: Request, res: Response) { + try { + const newRundown = await dissolveBlock(req.params.eventId); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + export async function rundownDelete(_req: Request, res: Response) { try { await deleteAllEntries(); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 68f55e1d3..f3abb1321 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 { rundownApplyDelay, rundownBatchPut, rundownDelete, + rundownDissolveBlock, rundownGetAll, rundownGetById, rundownGetCurrent, @@ -37,6 +38,7 @@ 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.delete('/', rundownArrayOfIds, deletesEventById); router.delete('/all', rundownDelete); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index c8e2f2dbe..08112275d 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -214,6 +214,22 @@ export async function applyDelay(delayId: EntryId) { notifyChanges({ timer: true, external: true }); } +/** + * Deletes a block from the rundown and moves all its children to the top level + */ +export async function dissolveBlock(blockId: EntryId) { + const scopedMutation = cache.mutateCache(cache.dissolveBlock); + const { newRundown } = await scopedMutation({ blockId }); + + // notify runtime that rundown has changed + updateRuntimeOnChange(); + + // we dont need to modify the timer since the grouping does not affect the runtime + notifyChanges({ external: true }); + + return newRundown; +} + /** * swaps two events * @param {string} from - id of event from 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 f53026d54..5c58d7e9c 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -15,6 +15,7 @@ import { editCustomField, removeCustomField, customFieldChangelog, + dissolveBlock, } from '../rundownCache.js'; import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; import { ProcessedRundownMetadata } from '../rundownCache.utils.js'; @@ -765,6 +766,35 @@ describe('reorder() mutation', () => { }); }); +describe('dissolveBlock() mutation', () => { + it('should correctly dissolve a block into its events', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '2', '21', '22'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), + '2': makeOntimeBlock({ id: '2', events: ['21', '22'] }), + '21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }), + '22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }), + }, + }); + + const { newRundown } = dissolveBlock({ + rundown, + blockId: '2', + }); + + expect(newRundown.order).toStrictEqual(['1', '21', '22']); + expect(newRundown.flatOrder).toStrictEqual(['1', '21', '22']); + expect(newRundown.entries['2']).toBeUndefined(); + expect(newRundown.entries).toMatchObject({ + '1': { id: '1', type: SupportedEntry.Event, cue: 'data1', parent: null }, + '21': { id: '21', type: SupportedEntry.Event, cue: 'data21', parent: null }, + '22': { id: '22', type: SupportedEntry.Event, cue: 'data22', parent: null }, + }); + }); +}); + describe('swap() mutation', () => { it('should correctly swap data between events', () => { const rundown = makeRundown({ diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 0b72b81de..bb5a55b5f 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -509,6 +509,38 @@ export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn return { newRundown: rundown, didMutate: true }; } +type DissolveBlockArgs = MutationParams<{ blockId: EntryId }>; +/** + * Deletes a block and moves all its children to the top level order + * Mutates the given rundown + * @throws if block ID not found + */ +export function dissolveBlock({ rundown, blockId }: DissolveBlockArgs): MutatingReturn { + const block = rundown.entries[blockId]; + if (!isOntimeBlock(block)) { + throw new Error('Block with ID not found'); + } + + // get the events from the block and merge them into the order where the block was + const nestedEvents = block.events; + const blockIndex = rundown.order.indexOf(blockId); + rundown.order.splice(blockIndex, 1, ...nestedEvents); + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== blockId); + + // delete block from entries and remove its reference from the child events + delete rundown.entries[blockId]; + for (let i = 0; i < nestedEvents.length; i++) { + const eventId = nestedEvents[i]; + const entry = rundown.entries[eventId]; + if (!entry) { + throw new Error('Entry not found'); + } + (entry as OntimeEvent | OntimeDelay).parent = null; + } + + return { newRundown: rundown, didMutate: true }; +} + type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; /** * Swap two entries