diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 2849f274d..41fd721cb 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise> { - return axios.delete(`${rundownPath}/${eventId}`); +export async function requestDelete(eventIds: string[]): Promise> { + return axios.delete(rundownPath, { data: { ids: eventIds } }); } /** diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 5bacfdfd4..53cf2972e 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -337,7 +337,7 @@ export const useEventAction = () => { const _deleteEventMutation = useMutation({ mutationFn: requestDelete, // we optimistically update here - onMutate: async (eventId) => { + onMutate: async (eventIds: string[]) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); @@ -346,9 +346,11 @@ export const useEventAction = () => { if (previousData) { // optimistically update object - const newOrder = previousData.order.filter((id) => id !== eventId); + const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); const newRundown = { ...previousData.rundown }; - delete newRundown[eventId]; + for (const eventId of eventIds) { + delete newRundown[eventId]; + } queryClient.setQueryData(RUNDOWN, { order: newOrder, @@ -377,9 +379,9 @@ export const useEventAction = () => { * Deletes an event form the list */ const deleteEvent = useCallback( - async (eventId: string) => { + async (eventIds: string[]) => { try { - await _deleteEventMutation.mutateAsync(eventId); + await _deleteEventMutation.mutateAsync(eventIds); } catch (error) { logAxiosError('Error deleting event', error); } diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 24fe79638..b504302a1 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -45,7 +45,7 @@ export default function Rundown({ data }: RundownProps) { (cursor: string | null) => { if (!cursor) return; const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null; - deleteEvent(cursor); + deleteEvent([cursor]); setCursor(previous); }, [deleteEvent, order, rundown, setCursor], diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 1c20afd07..d97faddbb 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -58,18 +58,20 @@ export default function RundownEntry(props: RundownEntryProps) { const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const cursor = useAppMode((state) => state.cursor); const setCursor = useAppMode((state) => state.setCursor); - const { selectedEvents, clearSelectedEvents } = useEventSelection(); + const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const removeOpenEvent = useCallback(() => { - if (selectedEvents.has(data.id)) { - clearSelectedEvents(); - } - + unselect(data.id); // clear cursor if we are deleting the event that is currently selected if (cursor === data.id) { setCursor(null); } - }, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]); + }, [unselect, data.id, cursor, setCursor]); + + const clearMultiSelection = useCallback(() => { + clearSelectedEvents(); + setCursor(null); + }, [clearSelectedEvents, setCursor]); // Create / delete new events type FieldValue = { @@ -111,10 +113,12 @@ export default function RundownEntry(props: RundownEntryProps) { return swapEvents({ from: value as string, to: data.id }); } case 'delete': { - if (selectedEvents.has(data.id)) { - removeOpenEvent(); + if (selectedEvents.size > 1) { + clearMultiSelection(); + return deleteEvent(Array.from(selectedEvents)); } - return deleteEvent(data.id); + removeOpenEvent(); + return deleteEvent([data.id]); } case 'clone': { const newEvent = cloneEvent(data as OntimeEvent, data.id); @@ -134,7 +138,7 @@ export default function RundownEntry(props: RundownEntryProps) { if (selectedEvents.size > 1) { const changes: Partial = { [field]: value }; batchUpdateEvents(changes, Array.from(selectedEvents)); - return clearSelectedEvents(); + return; } if (field in data) { // @ts-expect-error -- not sure how to type this diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx index 2830499d7..3d8d8f205 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx @@ -50,7 +50,7 @@ export default function DelayBlock(props: DelayBlockProps) { }; const cancelDelayHandler = () => { - deleteEvent(data.id); + deleteEvent([data.id]); }; const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 8e3fb9e85..74d24597b 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -113,6 +113,7 @@ export default function EventBlock(props: EventBlockProps) { value: false, }), }, + { 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 d60606f1b..c58d291cc 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -13,6 +13,8 @@ interface EventSelectionStore { anchoredIndex: number | null; setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; clearSelectedEvents: () => void; + clearMultiSelect: () => void; + unselect: (id: string) => void; } export const useEventSelection = create()((set, get) => ({ @@ -81,7 +83,17 @@ export const useEventSelection = create()((set, get) => ({ }); } }, - clearSelectedEvents: () => set({ selectedEvents: new Set() }), + clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null }), + clearMultiSelect: () => { + const { selectedEvents } = get(); + const [firstSelected] = selectedEvents; + set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null }); + }, + unselect: (id: string) => { + const { selectedEvents } = get(); + selectedEvents.delete(id); + set({ selectedEvents }); + }, })); export function getSelectionMode(event: MouseEvent): SelectionMode { diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index f4302f11d..1eb5db5fd 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -119,10 +119,10 @@ export async function rundownDelete(_req: Request, res: Response) { +export async function deletesEventById(req: Request, res: Response) { try { - await deleteEvent(req.params.eventId); - res.status(204).send({ message: 'Event deleted' }); + await deleteEvent(req.body.ids); + res.status(204).send({ message: 'Events deleted' }); } catch (error) { const message = getErrorMessage(error); res.status(400).send({ message }); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 759f63b11..024be86a2 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -1,7 +1,7 @@ import express from 'express'; import { - deleteEventById, + deletesEventById, rundownApplyDelay, rundownBatchPut, rundownDelete, @@ -14,6 +14,7 @@ import { } from './rundown.controller.js'; import { paramsMustHaveEventId, + rundownArrayOfIds, rundownBatchPutValidator, rundownPostValidator, rundownPutValidator, @@ -35,5 +36,5 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); +router.delete('/', rundownArrayOfIds, deletesEventById); router.delete('/all', rundownDelete); -router.delete('/:eventId', paramsMustHaveEventId, deleteEventById); diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 8c36159df..ab539bd9b 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -64,3 +64,14 @@ export const paramsMustHaveEventId = [ next(); }, ]; + +export const rundownArrayOfIds = [ + body('ids').isArray().exists(), + body('ids.*').isString(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index fd245cbd6..431bdfc28 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -90,9 +90,9 @@ export async function addEvent(eventData: PatchWithId & { after?: string }): Pro * deletes event by its ID * @param eventId */ -export async function deleteEvent(eventId: string) { +export async function deleteEvent(eventIds: string[]) { const scopedMutation = cache.mutateCache(cache.remove); - const { didMutate } = await scopedMutation({ eventId }); + const { didMutate } = await scopedMutation({ eventIds }); if (didMutate === false) { return; @@ -102,7 +102,7 @@ export async function deleteEvent(eventId: string) { updateRuntimeOnChange(); // notify timer and external services of change - notifyChanges({ timer: [eventId], external: true }); + notifyChanges({ timer: eventIds, external: true }); } /** 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 7ca97616b..1219a19fc 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -347,9 +347,22 @@ describe('remove() mutation', () => { test('deletes an event from the rundown', () => { const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; const testRundown: OntimeRundown = [mockEvent]; - const { newRundown } = remove({ eventId: mockEvent.id, persistedRundown: testRundown }); + const { newRundown } = remove({ eventIds: [mockEvent.id], persistedRundown: testRundown }); expect(newRundown.length).toBe(0); }); + test('deletes multiple events from the rundown', () => { + const testRundown: OntimeRundown = [ + { type: SupportedEvent.Event, id: '1' } as OntimeEvent, + { type: SupportedEvent.Block, id: '2' } as OntimeBlock, + { type: SupportedEvent.Delay, id: '3' } as OntimeDelay, + { type: SupportedEvent.Event, id: '4' } as OntimeEvent, + { type: SupportedEvent.Event, id: '5' } as OntimeEvent, + { type: SupportedEvent.Event, id: '6' } as OntimeEvent, + ]; + const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown }); + expect(newRundown.length).toBe(3); + expect(newRundown.at(0).id).toBe('4'); + }); }); describe('edit() mutation', () => { diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index a6084fb8c..4218139b7 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -9,7 +9,7 @@ import { OntimeRundown, OntimeRundownEntry, } from 'ontime-types'; -import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils'; +import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { createPatch } from '../../utils/parser.js'; @@ -263,13 +263,12 @@ export function add({ persistedRundown, atIndex, event }: AddArgs): Required; +type RemoveArgs = MutationParams<{ eventIds: string[] }>; -export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn { - const atIndex = persistedRundown.findIndex((event) => event.id === eventId); - const newRundown = deleteAtIndex(atIndex, persistedRundown); +export function remove({ persistedRundown, eventIds }: RemoveArgs): MutatingReturn { + const newRundown = persistedRundown.filter((event) => !eventIds.includes(event.id)); - return { newRundown, didMutate: atIndex !== -1 }; + return { newRundown, didMutate: persistedRundown.length !== newRundown.length }; } export function removeAll(): MutatingReturn { diff --git a/packages/utils/src/array-utils/arrayUtils.ts b/packages/utils/src/array-utils/arrayUtils.ts index 49cb8780a..3209004ca 100644 --- a/packages/utils/src/array-utils/arrayUtils.ts +++ b/packages/utils/src/array-utils/arrayUtils.ts @@ -34,6 +34,12 @@ export function deleteAtIndex(index: number, array: T[]) { return array.filter((_, i) => i !== index); } +/** + * Reorders two objects in an array + * @param array + * @param fromIndex + * @param toIndex + */ export function reorderArray(array: T[], fromIndex: number, toIndex: number) { if (fromIndex === toIndex) { return array; // No change needed, return the original array