diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index e2cebb889..d79ef6769 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -153,6 +153,20 @@ export async function postCloneEntry( return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); } +export type PastePayload = { + entryIds: EntryId[]; + sourceRundownId: string; + afterId?: EntryId; + beforeId?: EntryId; +}; + +/** + * HTTP request for pasting entries into the active rundown + */ +export async function postPasteEntries(rundownId: RundownId, data: PastePayload): Promise> { + return axios.post(`${rundownPath}/${rundownId}/paste`, data); +} + /** * HTTP request for grouping a list of entries into a group */ diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 93bb7d094..c76049bbc 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -35,11 +35,13 @@ import { useCallback, useMemo } from 'react'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { RUNDOWN } from '../api/constants'; import { + PastePayload, ReorderEntry, deleteEntries, patchReorderEntry, postAddEntry, postCloneEntry, + postPasteEntries, putBatchEditEvents, putEditEntry, requestApplyDelay, @@ -270,6 +272,35 @@ export const useEntryActions = () => { [cloneEntryMutation, getCurrentRundownData], ); + /** + * Calls mutation to paste entries + * @private + */ + const { mutateAsync: pasteEntriesMutation } = useMutation({ + mutationFn: ([rundownId, data]: [string, PastePayload]) => postPasteEntries(rundownId, data), + onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), + onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + }); + + /** + * Paste entries from copy store into the active rundown + */ + const pasteEntries = useCallback( + async (data: PastePayload) => { + try { + const rundownId = getCurrentRundownData()?.id; + if (!rundownId) { + throw new Error('Rundown not initialised'); + } + + await pasteEntriesMutation([rundownId, data]); + } catch (error) { + logAxiosError('Error pasting entries', error); + } + }, + [pasteEntriesMutation, getCurrentRundownData], + ); + /** * Calls mutation to update existing entry * @private @@ -919,6 +950,7 @@ export const useEntryActions = () => { clone, deleteEntry, deleteAllEntries, + pasteEntries, ungroup, getEntryById, groupEntries, @@ -935,6 +967,7 @@ export const useEntryActions = () => { clone, deleteEntry, deleteAllEntries, + pasteEntries, ungroup, getEntryById, groupEntries, diff --git a/apps/client/src/common/stores/entryCopyStore.ts b/apps/client/src/common/stores/entryCopyStore.ts index 4ccc2de7b..f705625b0 100644 --- a/apps/client/src/common/stores/entryCopyStore.ts +++ b/apps/client/src/common/stores/entryCopyStore.ts @@ -1,14 +1,16 @@ import { create } from 'zustand'; type EntryCopyStore = { - entryCopyId: string | null; - entryCopyMode: 'copy' | 'cut'; - setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void; + entryIds: Set; + sourceRundownId: string | null; + setCopyEntries: (ids: string[], rundownId: string) => void; + clearCopy: () => void; }; export const useEntryCopy = create()((set) => ({ - entryCopyId: null, - entryCopyMode: 'copy', - setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') => - set({ entryCopyId, entryCopyMode: mode }), + entryIds: new Set(), + sourceRundownId: null, + setCopyEntries: (ids: string[], rundownId: string) => + set({ entryIds: new Set(ids), sourceRundownId: rundownId }), + clearCopy: () => set({ entryIds: new Set(), sourceRundownId: null }), })); diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 1118652a0..50cdacfc1 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -13,7 +13,6 @@ import { TbFlagFilled } from 'react-icons/tb'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext'; -import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata'; import { AppMode } from '../../ontimeConfig'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; @@ -64,7 +63,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id); const entryActions = useEntryActionsContext(); - const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId); + const selectedEvents = useEventSelection((state) => state.selectedEvents); // cursor const { editorMode } = useEditorFollowMode(); @@ -105,9 +104,10 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata // Keyboard shortcuts useRundownKeyboard({ cursor, + rundownId: id, + selectedEvents, commands, clearSelectedEvents, - setEntryCopyId, }); // DND handlers diff --git a/apps/client/src/features/rundown/entry-editor/EventEditorEmpty.tsx b/apps/client/src/features/rundown/entry-editor/EventEditorEmpty.tsx index 0097de080..7a10cbfa2 100644 --- a/apps/client/src/features/rundown/entry-editor/EventEditorEmpty.tsx +++ b/apps/client/src/features/rundown/entry-editor/EventEditorEmpty.tsx @@ -96,14 +96,6 @@ function EventEditorEmpty() { C - - Cut selected entry - - {deviceMod} - + - X - - Paste above diff --git a/apps/client/src/features/rundown/hooks/useRundownCommands.ts b/apps/client/src/features/rundown/hooks/useRundownCommands.ts index c4b2eb4d6..c56d0fe05 100644 --- a/apps/client/src/features/rundown/hooks/useRundownCommands.ts +++ b/apps/client/src/features/rundown/hooks/useRundownCommands.ts @@ -26,7 +26,7 @@ export function useRundownCommands({ selectEntry: applySelection, handleCollapseGroup, }: UseRundownCommandsOptions) { - const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions; + const { addEntry, clone, deleteEntry, move, pasteEntries } = entryActions; const deleteAtCursor = useCallback( (cursor: string | null) => { @@ -40,52 +40,21 @@ export function useRundownCommands({ [entries, flatOrder, deleteEntry, applySelection], ); - const insertCopyAtId = useCallback( - (atId: EntryId | null, above = false) => { - // lazily get the value from the store - const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState(); - if (entryCopyId === null || !entries[entryCopyId]) { - // we cant clone without selection + const pasteAtCursor = useCallback( + (cursor: EntryId | null, above = false) => { + const { entryIds, sourceRundownId } = useEntryCopy.getState(); + if (entryIds.size === 0 || !sourceRundownId) { return; } - let normalisedAtId = atId; - - const elementToCopy = entries[entryCopyId]; - const refElement = atId ? entries[atId] : undefined; - - if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) { - normalisedAtId = refElement.parent; - } - - if (entryCopyMode === 'cut') { - if (!normalisedAtId) { - const firstId = flatOrder[0]; - if (!firstId || firstId === entryCopyId) { - return; - } - reorderEntry(entryCopyId, firstId, 'before') - .then(() => setEntryCopyId(null)) - .catch(() => {}); - return; - } - if (normalisedAtId === entryCopyId) { - return; - } - const placement = above ? 'before' : 'after'; - reorderEntry(entryCopyId, normalisedAtId, placement) - .then(() => setEntryCopyId(null)) - .catch(() => {}); - return; - } - - clone(entryCopyId, { - after: above ? undefined : (normalisedAtId ?? undefined), - // if we don't have a cursor add the new event on top - before: above ? (normalisedAtId ?? undefined) : undefined, + pasteEntries({ + entryIds: Array.from(entryIds), + sourceRundownId, + afterId: above ? undefined : (cursor ?? undefined), + beforeId: above ? (cursor ?? undefined) : undefined, }); }, - [entries, flatOrder, clone, reorderEntry], + [pasteEntries], ); /** @@ -217,7 +186,7 @@ export function useRundownCommands({ return { cloneEntry, deleteAtCursor, - insertCopyAtId, + pasteAtCursor, insertAtId, selectGroup, selectEntry, diff --git a/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts b/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts index 01d4c8fbf..b17111b88 100644 --- a/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts +++ b/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts @@ -6,6 +6,8 @@ import { useEventSelection } from '../useEventSelection'; interface UseRundownKeyboardOptions { cursor: EntryId | null; + rundownId: string | undefined; + selectedEvents: Set; commands: { selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null; selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null; @@ -15,10 +17,9 @@ interface UseRundownKeyboardOptions { moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void; deleteAtCursor: (cursor: EntryId | null) => void; insertAtId: (patch: Partial & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void; - insertCopyAtId: (atId: EntryId | null, above?: boolean) => void; + pasteAtCursor: (cursor: EntryId | null, above?: boolean) => void; }; clearSelectedEvents: () => void; - setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void; } /** @@ -37,9 +38,10 @@ function isEditableElement(target: EventTarget | null): boolean { export function useRundownKeyboard({ cursor, + rundownId, + selectedEvents, commands, clearSelectedEvents, - setEntryCopyId, }: UseRundownKeyboardOptions) { const scrollToEntry = useEventSelection((state) => state.scrollToEntry); @@ -138,7 +140,7 @@ export function useRundownKeyboard({ 'Escape', () => { clearSelectedEvents(); - setEntryCopyId(null); + useEntryCopy.getState().clearCopy(); }, { preventDefault: true, usePhysicalKeys: true }, ], @@ -192,33 +194,25 @@ export function useRundownKeyboard({ [ 'mod + C', (event) => { - if (cursor === null || isEditableElement(event.target)) { + if (cursor === null || isEditableElement(event.target) || !rundownId) { return; } event.preventDefault(); - setEntryCopyId(cursor); - }, - { usePhysicalKeys: true }, - ], - [ - 'mod + X', - (event) => { - if (cursor === null || isEditableElement(event.target)) { - return; - } - event.preventDefault(); - setEntryCopyId(cursor, 'cut'); + + // if multiple events are selected, copy all of them; otherwise copy the cursor entry + const ids = selectedEvents.size > 1 ? Array.from(selectedEvents) : [cursor]; + useEntryCopy.getState().setCopyEntries(ids, rundownId); }, { usePhysicalKeys: true }, ], [ 'mod + V', (event) => { - if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) { + if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) { return; } event.preventDefault(); - commands.insertCopyAtId(cursor); + commands.pasteAtCursor(cursor); }, { usePhysicalKeys: true }, ], @@ -226,11 +220,11 @@ export function useRundownKeyboard({ [ 'mod + shift + V', (event) => { - if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) { + if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) { return; } event.preventDefault(); - commands.insertCopyAtId(cursor, true); + commands.pasteAtCursor(cursor, true); }, { usePhysicalKeys: true }, ], diff --git a/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss b/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss index 3ea540f08..d5541c12a 100644 --- a/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss +++ b/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss @@ -17,10 +17,6 @@ outline: 1px solid $block-cursor-color; } - &.copyTarget { - outline: 1px dashed $blue-500; - outline-offset: -2px; - } } .drag { diff --git a/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx b/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx index 753a2166d..11ed4540e 100644 --- a/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx +++ b/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx @@ -6,7 +6,6 @@ import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5'; import Button from '../../../common/components/buttons/Button'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; -import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { cx } from '../../../common/utils/styleUtils'; import DelayInput from './DelayInput'; @@ -22,7 +21,6 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) { const { applyDelay, deleteEntry } = useEntryActionsContext(); const handleRef = useRef(null); - const entryCopyId = useEntryCopy((state) => state.entryCopyId); const { attributes: dragAttributes, @@ -61,7 +59,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) { return (
state.setSelectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents); - const entryCopyId = useEntryCopy((state) => state.entryCopyId); const handleRef = useRef(null); @@ -248,7 +246,6 @@ export default function RundownEvent({ playback && style[playback], isSelected && style.selected, hasCursor && style.hasCursor, - entryCopyId === eventId && style.copyTarget, ]); const handleFocusClick = (event: MouseEvent) => { diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss b/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss index f82a65f12..a719e2e81 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss @@ -13,10 +13,6 @@ outline: 1px solid $block-cursor-color; } - &.copyTarget { - outline: 2px dashed $block-cursor-color; - } - &.expanded { margin-block: 0.5rem 0; border-radius: $block-border-radius $block-border-radius 0 0; diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index 1db4796ad..1bddc929b 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -16,7 +16,6 @@ import IconButton from '../../../common/components/buttons/IconButton'; import Tag from '../../../common/components/tag/Tag'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; -import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { deviceMod } from '../../../common/utils/deviceUtils'; import { getOffsetState } from '../../../common/utils/offset'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils'; @@ -43,7 +42,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); const selectedEvents = useEventSelection((state) => state.selectedEvents); - const entryCopyId = useEntryCopy((state) => state.entryCopyId); const [onContextMenu] = useContextMenu(() => [ { @@ -133,7 +131,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: style.group, hasCursor && style.hasCursor, !collapsed && style.expanded, - entryCopyId === data.id && style.copyTarget, ])} ref={setNodeRef} onClick={handleFocusClick} diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss index b08f6c092..e88d8d2d1 100644 --- a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss @@ -18,9 +18,6 @@ outline: 1px solid $block-cursor-color; } - &.copyTarget { - outline: 2px dashed $block-cursor-color; - } } .binder { diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx index 35516f35b..4754b0b05 100644 --- a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx @@ -8,7 +8,6 @@ import Input from '../../../common/components/input/input/Input'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; -import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { deviceMod } from '../../../common/utils/deviceUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { useEventSelection } from '../useEventSelection'; @@ -31,7 +30,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); - const entryCopyId = useEntryCopy((state) => state.entryCopyId); const [onContextMenu] = useContextMenu(() => [ { @@ -89,7 +87,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl className={cx([ style.milestone, hasCursor ? style.hasCursor : null, - entryCopyId === entryId ? style.copyTarget : null, ])} ref={setNodeRef} onClick={handleFocusClick} diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 996900c59..ea0ce58e9 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -18,6 +18,7 @@ import { groupEntries, initRundown, loadRundown, + pasteEntries, reorderEntry, swapEvents, ungroupEntries, @@ -30,6 +31,7 @@ import { entryPutValidator, entryReorderValidator, entrySwapValidator, + pastePostValidator, rundownArrayOfIds, rundownPostValidator, validateRundownMutation, @@ -302,6 +304,29 @@ router.post( }, ); +/** + * Pastes entries from a source rundown into the active rundown + */ +router.post( + '/:rundownId/paste', + pastePostValidator, + validateRundownMutation, + async (req: Request, res: Response) => { + try { + const rundown = await pasteEntries({ + entryIds: req.body.entryIds, + sourceRundownId: req.body.sourceRundownId, + afterId: req.body.afterId, + beforeId: req.body.beforeId, + }); + res.status(200).send(rundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } + }, +); + /** * Creates a group out of a list of entries */ diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 48a8873e5..123b6bebb 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -334,6 +334,102 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise { + const { entryIds, sourceRundownId, afterId, beforeId } = payload; + const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false }); + + // resolve source rundown + const sourceRundown = + sourceRundownId === rundown.id ? rundown : getDataProvider().getRundown(sourceRundownId); + + // validate all entry IDs exist in source + for (const entryId of entryIds) { + if (!sourceRundown.entries[entryId]) { + throw new Error(`Entry with ID ${entryId} not found in source rundown`); + } + } + + // resolve insertion position + let currentAfterId: EntryId | undefined; + + if (afterId) { + currentAfterId = afterId; + } else if (beforeId) { + // for "before" positioning, pass it to the first clone and then chain after + currentAfterId = undefined; + } + + const newEntryIds: EntryId[] = []; + + // sort entry IDs by source rundown flatOrder so paste preserves rundown order + // regardless of the order they were selected (e.g. ctrl-click order) + const validIds = entryIds.filter((id) => sourceRundown.entries[id]); + const orderedIds = validIds.sort((a, b) => { + const idxA = sourceRundown.flatOrder.indexOf(a); + const idxB = sourceRundown.flatOrder.indexOf(b); + return idxA - idxB; + }); + + for (let i = 0; i < orderedIds.length; i++) { + const entryId = orderedIds[i]; + const sourceEntry = sourceRundown.entries[entryId]; + + // resolve position reference for this entry + // when pasting a group and the reference is a child inside another group, + // normalise to the parent group so the pasted group lands at the top level + const options: InsertOptions = {}; + if (i === 0 && beforeId && !afterId) { + options.before = normaliseGroupPosition(rundown, sourceEntry, beforeId); + } else if (currentAfterId) { + options.after = normaliseGroupPosition(rundown, sourceEntry, currentAfterId); + } + + const newEntry = rundownMutation.clone(rundown, sourceEntry, options); + newEntryIds.push(newEntry.id); + + // chain: each new entry becomes the afterId for the next + currentAfterId = newEntry.id; + } + + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + updateRuntimeOnChange(rundownMetadata); + notifyChanges(rundownMetadata, revision, { timer: newEntryIds, external: true }); + }); + + return rundownResult; +} + +/** + * When pasting a group, the position reference must be a top-level entry. + * If the reference is a child inside another group, resolve to the parent + * so the pasted group ends up at the correct position in rundown.order. + */ +function normaliseGroupPosition(rundown: Rundown, sourceEntry: OntimeEntry, referenceId: EntryId): EntryId { + if (!isOntimeGroup(sourceEntry)) { + return referenceId; + } + + const referenceEntry = rundown.entries[referenceId]; + if (referenceEntry && 'parent' in referenceEntry && referenceEntry.parent) { + return referenceEntry.parent; + } + + return referenceId; +} + /** * Clones an entry, ensuring that all dependencies are preserved * Handles cloning children if the entry is a group diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 4d0d0d946..72652f2be 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -83,4 +83,14 @@ export const rundownArrayOfIds = [ requestValidationFunction, ]; +export const pastePostValidator = [ + body('entryIds').isArray().notEmpty(), + body('entryIds.*').isString(), + body('sourceRundownId').isString().notEmpty(), + body('afterId').optional().isString(), + body('beforeId').optional().isString(), + + requestValidationFunction, +]; + // #endregion operations on rundown entries ======================= diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index c518288a0..e2ddad78f 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -31,7 +31,7 @@ test('Copy-paste', async ({ page }) => { await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4'); }); -test('Cut-paste', async ({ page }) => { +test('Multi-event copy-paste', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); await page.getByRole('button', { name: 'Edit' }).click(); @@ -40,27 +40,39 @@ test('Cut-paste', async ({ page }) => { await page.getByRole('menuitem', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click(); - // create events + // create three events with distinct titles await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByTestId('entry-1').getByTestId('entry__title').click(); - await page.getByTestId('entry-1').getByTestId('entry__title').fill('first'); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('alpha'); await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); await page.getByRole('button', { name: 'Event' }).nth(4).click(); await page.getByTestId('entry-2').getByTestId('entry__title').click(); - await page.getByTestId('entry-2').getByTestId('entry__title').fill('second'); + await page.getByTestId('entry-2').getByTestId('entry__title').fill('beta'); await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter'); - // cut first event, paste below second - await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click(); - await page.getByTestId('entry-1').getByTestId('rundown-event').filter({ hasText: '1' }).press('ControlOrMeta+x'); - await page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2').click(); - await page.getByTestId('entry-2').getByTestId('rundown-event').filter({ hasText: '2' }).press('ControlOrMeta+v'); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); + await page.getByTestId('entry-3').getByTestId('entry__title').click(); + await page.getByTestId('entry-3').getByTestId('entry__title').fill('gamma'); + await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter'); - // we can verify that the entries have swapped places - const events = await page.getByTestId('entry__title').all(); - await expect(events[0]).toHaveValue('second'); - await expect(events[1]).toHaveValue('first'); + // multi-select first and third events via ctrl-click + await page.getByTestId('entry-1').getByTestId('rundown-event').click(); + await page.getByTestId('entry-3').getByTestId('rundown-event').click({ modifiers: ['Control'] }); + + // copy and paste after the last event + await page.keyboard.press('ControlOrMeta+c'); + await page.getByTestId('entry-3').getByTestId('rundown-event').click(); + await page.keyboard.press('ControlOrMeta+v'); + + // should now have 5 events: alpha, beta, gamma, alpha-copy, gamma-copy + await expect(page.getByTestId('rundown-event')).toHaveCount(5); + const titles = await page.getByTestId('entry__title').all(); + await expect(titles[0]).toHaveValue('alpha'); + await expect(titles[1]).toHaveValue('beta'); + await expect(titles[2]).toHaveValue('gamma'); + await expect(titles[3]).toHaveValue('alpha'); + await expect(titles[4]).toHaveValue('gamma'); }); test('Move', async ({ page }) => {