From 32bf0f53f675a12584ee5fc83fcd4271f735446d Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 26 Oct 2025 13:35:10 +0100 Subject: [PATCH] feat: allow user to duplicate a rundown --- apps/client/src/common/api/rundown.ts | 7 ++++++ .../common/hooks-query/useProjectRundowns.ts | 14 +++++++++-- .../panel/manage-panel/ManageRundowns.tsx | 23 +++++++++++++++++-- .../rundown/__tests__/rundown.utils.test.ts | 22 ++++++++++++++++++ .../src/api-data/rundown/rundown.router.ts | 20 +++++++++++++++- .../src/api-data/rundown/rundown.utils.ts | 16 +++++++++++++ 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 76b2db573..46ad2110b 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -38,6 +38,13 @@ export async function createRundown(title: string): Promise> { + return axios.post(`${rundownPath}/${rundownId}/duplicate`); +} + /** * HTTP request to delete a rundown */ diff --git a/apps/client/src/common/hooks-query/useProjectRundowns.ts b/apps/client/src/common/hooks-query/useProjectRundowns.ts index 185dbc406..7e8dc4284 100644 --- a/apps/client/src/common/hooks-query/useProjectRundowns.ts +++ b/apps/client/src/common/hooks-query/useProjectRundowns.ts @@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { PROJECT_RUNDOWNS } from '../api/constants'; -import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown'; +import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown } from '../api/rundown'; /** * Project rundowns @@ -30,6 +30,16 @@ export function useMutateProjectRundowns() { ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data); }, }); + + const { mutateAsync: duplicate } = useMutation({ + mutationFn: duplicateRundown, + onMutate: () => { + ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS }); + }, + onSuccess: (response) => { + ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data); + }, + }); const { mutateAsync: remove } = useMutation({ mutationFn: deleteRundown, @@ -51,5 +61,5 @@ export function useMutateProjectRundowns() { }, }); - return { create, remove, load }; + return { create, duplicate, remove, load }; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx index b36aef920..673ab940a 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5'; +import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoDuplicateOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5'; import { useDisclosure } from '@mantine/hooks'; import { downloadAsExcel } from '../../../../common/api/excel'; @@ -19,7 +19,7 @@ import style from './ManagePanel.module.scss'; export default function ManageRundowns() { const { data } = useProjectRundowns(); - const { remove, load } = useMutateProjectRundowns(); + const { duplicate, remove, load } = useMutateProjectRundowns(); const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenLoad, loadHandlers] = useDisclosure(); const [isNewLoad, newHandlers] = useDisclosure(); @@ -48,6 +48,18 @@ export default function ManageRundowns() { } }; + const submitRundownDuplicate = async (id: string) => { + setActionError(null); + setRenamingRundown(null); + setTargetRundown(''); + + try { + await duplicate(id); + } catch (error) { + setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`); + } + }; + const submitRundownDelete = async () => { try { await remove(targetRundown); @@ -117,6 +129,13 @@ export default function ManageRundowns() { label: 'Download .xlsx', onClick: () => handleDownloadXlsx(id, title), }, + { + type: 'item', + icon: IoDuplicateOutline, + label: 'Duplicate', + onClick: () => submitRundownDuplicate(id), + }, + { type: 'divider' }, { type: 'item', icon: IoTrash, diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index 77edb2c69..453377d89 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -3,11 +3,14 @@ import { MILLIS_PER_HOUR } from 'ontime-utils'; import { assertType } from 'vitest'; +import { demoDb } from '../../../models/demoProject.js'; + import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, + duplicateRundown, getInsertAfterId, hasChanges, } from '../rundown.utils.js'; @@ -260,3 +263,22 @@ describe('getInsertAfterId()', () => { expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31'); }); }); + +describe('duplicateRundown', () => { + it("duplicates a given rundown", () => { + const demoRundown = demoDb.rundowns['default']; + const title = 'Duplicated Rundown'; + const duplicatedRundown = duplicateRundown(demoRundown, title); + + expect(duplicatedRundown).toMatchObject({ + title: title, + entries: expect.any(Object), + order: expect.any(Array), + flatOrder: expect.any(Array), + }) + expect(demoRundown.id).not.toEqual(duplicatedRundown.id); + expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length); + expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length); + expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length); + }) +}) \ No newline at end of file diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index cb3b437ee..1c1a26668 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -32,7 +32,7 @@ import { import { paramsWithId } from '../validation-utils/validationFunction.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { defaultRundown } from '../../models/dataModel.js'; -import { normalisedToRundownArray } from './rundown.utils.js'; +import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js'; export const router = express.Router(); @@ -95,6 +95,24 @@ router.post('/', rundownPostValidator, async (req: Request, res: Response) => { + try { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(req.params.id); + + const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`); + await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown); + + const projectRundowns = getDataProvider().getProjectRundowns(); + res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); /** * Deletes a rundown if not loaded */ diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 90788a4be..ed6fd417d 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -34,6 +34,7 @@ import { milestone as milestoneDef, } from '../../models/eventsDefinition.js'; import { makeString } from '../../utils/parserUtils.js'; + import { RundownMetadata } from './rundown.types.js'; type CompleteEntry = @@ -517,3 +518,18 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund return { id, numEntries: flatOrder.length, title, revision }; }); } + + +/** + * Duplicates an existing rundown ensuring all IDs are unique + */ +export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown { + const newRundownId = generateId(); + + const newRundown = structuredClone(rundown); + newRundown.id = newRundownId; + newRundown.title = newTitle; + newRundown.revision = 0; + + return newRundown; +} \ No newline at end of file