diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index fe66e8a32..23714f4a7 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -90,15 +90,15 @@ export async function requestApplyDelay(delayId: EntryId): Promise> { +export async function requestDissolveBlock(blockId: EntryId): Promise> { return axios.post(`${rundownPath}/dissolve/${blockId}`); } /** * HTTP request for grouping a list of entries into a block */ -export async function requestGroupEntries(entryIds: EntryId[]): Promise> { - return axios.post(`${rundownPath}/group`, { data: { ids: entryIds } }); +export async function requestGroupEntries(entryIds: EntryId[]): Promise> { + return axios.post(`${rundownPath}/group`, { ids: entryIds }); } /** diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 16ca95dea..f6bd8be3f 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -27,6 +27,7 @@ import { requestDeleteAll, requestDissolveBlock, requestEventSwap, + requestGroupEntries, SwapEntry, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; @@ -521,6 +522,19 @@ export const useEntryActions = () => { */ const _dissolveBlockMutation = useMutation({ mutationFn: requestDissolveBlock, + onSuccess: (response) => { + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); + }, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); @@ -537,6 +551,43 @@ export const useEntryActions = () => { }, [_dissolveBlockMutation], ); + + /** + * Calls mutation to create a block with a selection + * @private + */ + const _groupEntriesMutation = useMutation({ + mutationFn: requestGroupEntries, + onSuccess: (response) => { + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + }); + + /** + * Create a block with a selection + */ + const groupEntries = useCallback( + async (entryIds: EntryId[]) => { + try { + await _groupEntriesMutation.mutateAsync(entryIds); + } catch (error) { + logAxiosError('Error grouping entries', error); + } + }, + [_groupEntriesMutation], + ); + /** * Calls mutation to reorder an entry * @private @@ -575,17 +626,17 @@ export const useEntryActions = () => { // Mutation finished, we update the rundown with the response onSuccess: (response) => { - if (response.data) { - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - } + 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 @@ -688,6 +739,7 @@ export const useEntryActions = () => { deleteAllEntries, dissolveBlock, getEntryById, + groupEntries, reorderEntry, swapEvents, updateEntry, diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index bdee1045e..3d2cadd31 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -19,18 +19,17 @@ import EventBlock from './event-block/EventBlock'; import { useEventSelection } from './useEventSelection'; export type EventItemActions = - | 'set-cursor' | 'event' | 'event-before' | 'delay' | 'delay-before' | 'block' | 'block-before' + | 'swap' | 'delete' | 'clone' - | 'update' - | 'swap' - | 'clear-report'; + | 'group' + | 'update'; interface RundownEntryProps { type: SupportedEntry; @@ -66,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) { isLinkedToLoaded, } = props; const { emitError } = useEmitLog(); - const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions(); + const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const removeOpenEvent = useCallback(() => { @@ -129,6 +128,13 @@ export default function RundownEntry(props: RundownEntryProps) { addEntry(newEvent, { after: data.id }); break; } + case 'group': { + if (selectedEvents.size > 1) { + clearMultiSelection(); + return groupEntries(Array.from(selectedEvents)); + } + break; + } case 'update': { // Handles and filters update requests const { field, value } = payload as FieldValue; diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 73f6fd9a8..2e35f50a4 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react' import { IoAdd, IoDuplicateOutline, + IoFolder, IoLink, IoPeople, IoPeopleOutline, @@ -26,7 +27,7 @@ import RundownIndicators from './RundownIndicators'; import style from './EventBlock.module.scss'; interface EventBlockProps { - eventId: string; + eventId: EntryId; cue: string; timeStart: number; timeEnd: number; @@ -144,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) { value: false, }), }, + { withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, ] : [ diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index 697e0750e..f1591a7a4 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -9,13 +9,13 @@ import { isMacOS } from '../../common/utils/deviceUtils'; type SelectionMode = 'shift' | 'click' | 'ctrl'; interface EventSelectionStore { - selectedEvents: Set; + selectedEvents: Set; anchoredIndex: MaybeNumber; cursor: MaybeString; - setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; + setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; clearSelectedEvents: () => void; clearMultiSelect: () => void; - unselect: (id: string) => void; + unselect: (id: EntryId) => void; } export const useEventSelection = create()((set, get) => ({ diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index 2a3f6d59b..d00d9a5a4 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -12,6 +12,7 @@ import { deleteEvent, editEvent, dissolveBlock, + groupEntries, reorderEntry, swapEvents, } from '../../services/rundown-service/RundownService.js'; @@ -137,6 +138,16 @@ export async function rundownDissolveBlock(req: Request, res: Response) { + try { + const newRundown = await groupEntries(req.body.ids); + 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 f3abb1321..cd2dcb4a5 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -2,6 +2,7 @@ import express from 'express'; import { deletesEventById, + rundownAddToBlock, rundownApplyDelay, rundownBatchPut, rundownDelete, @@ -39,6 +40,7 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock); +router.post('/group', rundownArrayOfIds, rundownAddToBlock); router.delete('/', rundownArrayOfIds, deletesEventById); router.delete('/all', rundownDelete); diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index c61cc3e42..d06d5a90e 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -1,7 +1,7 @@ -import { OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; +import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils'; -import { event as eventDef } from '../../models/eventsDefinition.js'; +import { event as eventDef, block as blockDef } from '../../models/eventsDefinition.js'; import { makeString } from '../../utils/parserUtils.js'; export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial): OntimeEvent { @@ -68,6 +68,32 @@ export const createEvent = (eventArgs: Partial, eventIndex: number return event; }; +/** + * Creates a new block from an optional patch + */ +export function createBlock(patch?: Partial): OntimeBlock { + if (!patch) { + return { ...blockDef, id: generateId() }; + } + + return { + id: patch.id ?? generateId(), + type: SupportedEntry.Block, + title: patch.title ?? '', + note: patch.note ?? '', + events: patch.events ?? [], + skip: patch.skip ?? false, + colour: makeString(patch.colour, ''), + custom: patch.custom ?? {}, + revision: 0, + startTime: null, + endTime: null, + duration: 0, + isFirstLinked: false, + numEvents: patch.events?.length ?? 0, + }; +} + /** * Function infers strategy for a patch with only partial timer data * @param end diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 117fc4c6f..5c618210e 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -14,9 +14,9 @@ import { } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; -import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; +import { delay as delayDef } from '../../models/eventsDefinition.js'; import { sendRefetch } from '../../adapters/websocketAux.js'; -import { createEvent } from '../../api-data/rundown/rundown.utils.js'; +import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js'; import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; @@ -55,7 +55,7 @@ function generateEvent | Partial | P // TODO(v4): allow user to provide a larger patch of the block entry if (isOntimeBlock(eventData)) { - return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry; + return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry; } throw new Error('Invalid event type'); @@ -235,6 +235,22 @@ export async function dissolveBlock(blockId: EntryId) { return newRundown; } +/** + * Groups a list of entries into a block + */ +export async function groupEntries(entryIds: EntryId[]) { + const scopedMutation = cache.mutateCache(cache.groupEntries); + const { newRundown } = await scopedMutation({ entryIds }); + + // 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 5c58d7e9c..b9a732e75 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -16,6 +16,7 @@ import { removeCustomField, customFieldChangelog, dissolveBlock, + groupEntries, } from '../rundownCache.js'; import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; import { ProcessedRundownMetadata } from '../rundownCache.utils.js'; @@ -795,6 +796,39 @@ describe('dissolveBlock() mutation', () => { }); }); +describe('groupEntries() mutation', () => { + it('groups a list of existing events into a new block', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', parent: null }), + '2': makeOntimeEvent({ id: '2', parent: null }), + '3': makeOntimeEvent({ id: '3', parent: null }), + }, + }); + + const { newRundown } = groupEntries({ + rundown, + entryIds: ['1', '2'], + }); + + const blockId = newRundown.order[0]; + expect(blockId).toStrictEqual(expect.any(String)); + expect(newRundown.order).toStrictEqual([expect.any(String), '3']); + expect(newRundown.flatOrder).toStrictEqual([expect.any(String), '1', '2', '3']); + expect(newRundown.entries).toMatchObject({ + [blockId]: { + type: SupportedEntry.Block, + events: ['1', '2'], + }, + '1': { id: '1', type: SupportedEntry.Event, parent: blockId }, + '2': { id: '2', type: SupportedEntry.Event, parent: blockId }, + '3': { id: '3', type: SupportedEntry.Event, 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 bb5a55b5f..cbf2b764b 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -11,11 +11,19 @@ import { OntimeEntry, Rundown, RundownEntries, + OntimeDelay, } from 'ontime-types'; -import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils'; +import { + generateId, + insertAtIndex, + reorderArray, + swapEventData, + customFieldLabelToKey, + mergeAtIndex, +} from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import { createPatch } from '../../api-data/rundown/rundown.utils.js'; +import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js'; import type { RundownMetadata } from './rundown.types.js'; import { apply } from './delayUtils.js'; @@ -541,6 +549,59 @@ export function dissolveBlock({ rundown, blockId }: DissolveBlockArgs): Mutating return { newRundown: rundown, didMutate: true }; } +type GroupArgs = MutationParams<{ entryIds: EntryId[] }>; +/** + * Groups a list of entries into a block + * It ensures that the entries get reassigned parent and the block gets a list of events + * The block will be created at the index of the first event in the order, not at the lowest index + * Mutates the given rundown + * @throws if any of the entries is a block + * @throws if any of the entries is not found + */ +export function groupEntries({ rundown, entryIds }: GroupArgs): MutatingReturn { + const block = createBlock({ id: getUniqueId() }); + + const nestedEvents: EntryId[] = []; + let firstIndex = -1; + for (let i = 0; i < entryIds.length; i++) { + const entryId = entryIds[i]; + const entry = rundown.entries[entryId]; + if (!entry) { + throw new Error('Entry not found'); + } + + if (isOntimeBlock(entry)) { + throw new Error('Cannot group a block'); + } + + if (entry.parent !== null) { + throw new Error('Entry already has a parent'); + } + + // the block will be created at the first selected event position + // note that this is not the lowest index + if (firstIndex === -1) { + firstIndex = rundown.flatOrder.indexOf(entryId); + } + + nestedEvents.push(entryId); + entry.parent = block.id; + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); + rundown.order = rundown.order.filter((id) => id !== entryId); + } + + block.events = nestedEvents; + const insertIndex = Math.max(0, firstIndex); + // we have filtered the items from the order + // we will insert them now, with only the block at top level ... + rundown.order = insertAtIndex(insertIndex, block.id, rundown.order); + /// ... and the nested elements after the block in the flat order + rundown.flatOrder = mergeAtIndex(insertIndex, [block.id, ...nestedEvents], rundown.flatOrder); + rundown.entries[block.id] = block; + + return { newRundown: rundown, didMutate: true }; +} + type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; /** * Swap two entries