feat: create group from entry selection

This commit is contained in:
Carlos Valente
2025-05-17 15:17:00 +02:00
committed by Carlos Valente
parent 67f914bdc0
commit 7d5291e5e5
11 changed files with 240 additions and 30 deletions
+3 -3
View File
@@ -90,15 +90,15 @@ export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse
/** /**
* HTTP request for dissolving of a block * HTTP request for dissolving of a block
*/ */
export async function requestDissolveBlock(blockId: EntryId): Promise<AxiosResponse<MessageResponse>> { export async function requestDissolveBlock(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/dissolve/${blockId}`); return axios.post(`${rundownPath}/dissolve/${blockId}`);
} }
/** /**
* HTTP request for grouping a list of entries into a block * HTTP request for grouping a list of entries into a block
*/ */
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> { export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { data: { ids: entryIds } }); return axios.post(`${rundownPath}/group`, { ids: entryIds });
} }
/** /**
+63 -11
View File
@@ -27,6 +27,7 @@ import {
requestDeleteAll, requestDeleteAll,
requestDissolveBlock, requestDissolveBlock,
requestEventSwap, requestEventSwap,
requestGroupEntries,
SwapEntry, SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
@@ -521,6 +522,19 @@ export const useEntryActions = () => {
*/ */
const _dissolveBlockMutation = useMutation({ const _dissolveBlockMutation = useMutation({
mutationFn: requestDissolveBlock, mutationFn: requestDissolveBlock,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -537,6 +551,43 @@ export const useEntryActions = () => {
}, },
[_dissolveBlockMutation], [_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>(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 * Calls mutation to reorder an entry
* @private * @private
@@ -575,17 +626,17 @@ export const useEntryActions = () => {
// Mutation finished, we update the rundown with the response // Mutation finished, we update the rundown with the response
onSuccess: (response) => { onSuccess: (response) => {
if (response.data) { if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, { const { id, title, order, flatOrder, entries, revision } = response.data;
id, queryClient.setQueryData<Rundown>(RUNDOWN, {
title, id,
order, title,
flatOrder, order,
entries, flatOrder,
revision, entries,
}); revision,
} });
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
@@ -688,6 +739,7 @@ export const useEntryActions = () => {
deleteAllEntries, deleteAllEntries,
dissolveBlock, dissolveBlock,
getEntryById, getEntryById,
groupEntries,
reorderEntry, reorderEntry,
swapEvents, swapEvents,
updateEntry, updateEntry,
@@ -19,18 +19,17 @@ import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
export type EventItemActions = export type EventItemActions =
| 'set-cursor'
| 'event' | 'event'
| 'event-before' | 'event-before'
| 'delay' | 'delay'
| 'delay-before' | 'delay-before'
| 'block' | 'block'
| 'block-before' | 'block-before'
| 'swap'
| 'delete' | 'delete'
| 'clone' | 'clone'
| 'update' | 'group'
| 'swap' | 'update';
| 'clear-report';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEntry; type: SupportedEntry;
@@ -66,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isLinkedToLoaded, isLinkedToLoaded,
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions(); const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => { const removeOpenEvent = useCallback(() => {
@@ -129,6 +128,13 @@ export default function RundownEntry(props: RundownEntryProps) {
addEntry(newEvent, { after: data.id }); addEntry(newEvent, { after: data.id });
break; break;
} }
case 'group': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return groupEntries(Array.from(selectedEvents));
}
break;
}
case 'update': { case 'update': {
// Handles and filters update requests // Handles and filters update requests
const { field, value } = payload as FieldValue; const { field, value } = payload as FieldValue;
@@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { import {
IoAdd, IoAdd,
IoDuplicateOutline, IoDuplicateOutline,
IoFolder,
IoLink, IoLink,
IoPeople, IoPeople,
IoPeopleOutline, IoPeopleOutline,
@@ -26,7 +27,7 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockProps { interface EventBlockProps {
eventId: string; eventId: EntryId;
cue: string; cue: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
@@ -144,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false, value: false,
}), }),
}, },
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
] ]
: [ : [
@@ -9,13 +9,13 @@ import { isMacOS } from '../../common/utils/deviceUtils';
type SelectionMode = 'shift' | 'click' | 'ctrl'; type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore { interface EventSelectionStore {
selectedEvents: Set<string>; selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber; anchoredIndex: MaybeNumber;
cursor: MaybeString; cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
clearMultiSelect: () => void; clearMultiSelect: () => void;
unselect: (id: string) => void; unselect: (id: EntryId) => void;
} }
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
@@ -12,6 +12,7 @@ import {
deleteEvent, deleteEvent,
editEvent, editEvent,
dissolveBlock, dissolveBlock,
groupEntries,
reorderEntry, reorderEntry,
swapEvents, swapEvents,
} from '../../services/rundown-service/RundownService.js'; } from '../../services/rundown-service/RundownService.js';
@@ -137,6 +138,16 @@ export async function rundownDissolveBlock(req: Request, res: Response<Rundown |
} }
} }
export async function rundownAddToBlock(req: Request, res: Response<Rundown | ErrorResponse>) {
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<MessageResponse | ErrorResponse>) { export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
await deleteAllEntries(); await deleteAllEntries();
@@ -2,6 +2,7 @@ import express from 'express';
import { import {
deletesEventById, deletesEventById,
rundownAddToBlock,
rundownApplyDelay, rundownApplyDelay,
rundownBatchPut, rundownBatchPut,
rundownDelete, rundownDelete,
@@ -39,6 +40,7 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock); router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock);
router.post('/group', rundownArrayOfIds, rundownAddToBlock);
router.delete('/', rundownArrayOfIds, deletesEventById); router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete); router.delete('/all', rundownDelete);
@@ -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 { 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'; import { makeString } from '../../utils/parserUtils.js';
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent { export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
@@ -68,6 +68,32 @@ export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number
return event; return event;
}; };
/**
* Creates a new block from an optional patch
*/
export function createBlock(patch?: Partial<OntimeBlock>): 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 * Function infers strategy for a patch with only partial timer data
* @param end * @param end
@@ -14,9 +14,9 @@ import {
} from 'ontime-types'; } from 'ontime-types';
import { getCueCandidate } from 'ontime-utils'; 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 { 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 { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js'; import { runtimeService } from '../runtime-service/RuntimeService.js';
@@ -55,7 +55,7 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
// TODO(v4): allow user to provide a larger patch of the block entry // TODO(v4): allow user to provide a larger patch of the block entry
if (isOntimeBlock(eventData)) { if (isOntimeBlock(eventData)) {
return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>; return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
} }
throw new Error('Invalid event type'); throw new Error('Invalid event type');
@@ -235,6 +235,22 @@ export async function dissolveBlock(blockId: EntryId) {
return newRundown; 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 * swaps two events
* @param {string} from - id of event from * @param {string} from - id of event from
@@ -16,6 +16,7 @@ import {
removeCustomField, removeCustomField,
customFieldChangelog, customFieldChangelog,
dissolveBlock, dissolveBlock,
groupEntries,
} from '../rundownCache.js'; } from '../rundownCache.js';
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
import { ProcessedRundownMetadata } from '../rundownCache.utils.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', () => { describe('swap() mutation', () => {
it('should correctly swap data between events', () => { it('should correctly swap data between events', () => {
const rundown = makeRundown({ const rundown = makeRundown({
@@ -11,11 +11,19 @@ import {
OntimeEntry, OntimeEntry,
Rundown, Rundown,
RundownEntries, RundownEntries,
OntimeDelay,
} from 'ontime-types'; } 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 { 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 type { RundownMetadata } from './rundown.types.js';
import { apply } from './delayUtils.js'; import { apply } from './delayUtils.js';
@@ -541,6 +549,59 @@ export function dissolveBlock({ rundown, blockId }: DissolveBlockArgs): Mutating
return { newRundown: rundown, didMutate: true }; 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 }>; type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>;
/** /**
* Swap two entries * Swap two entries