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 063bdba12..5a4ce36d0 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 @@ -2,13 +2,11 @@ import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types'; import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils'; import { assertType } from 'vitest'; -import { demoDb } from '../../../models/demoProject.js'; import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js'; import { calculateDayOffset, deleteById, doesInvalidateMetadata, - duplicateRundown, getIntegerAndFraction, hasChanges, makeDeepClone, @@ -223,25 +221,6 @@ describe('calculateDayOffset()', () => { }); }); -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); - }); -}); - describe('makeDeepClone()', () => { it('deep clones a group along with its nested entries', () => { const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] }); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 994b43c70..d0eb2ee54 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -15,16 +15,18 @@ import { createNewRundown, deleteAllEntries, deleteEntries, + deleteRundown, + duplicateRundown, editEntry, groupEntries, - initRundown, loadRundown, + renameRundown, renumberEntries, reorderEntry, swapEvents, ungroupEntries, } from './rundown.service.js'; -import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js'; +import { normalisedToRundownArray } from './rundown.utils.js'; import { clonePostValidator, entryBatchPutValidator, @@ -34,6 +36,7 @@ import { entryReorderValidator, entrySwapValidator, rundownArrayOfIds, + rundownPatchValidator, rundownPostValidator, } from './rundown.validation.js'; @@ -44,7 +47,7 @@ export const router: Router = express.Router(); /** * Returns all rundowns in the project */ -router.get('/', async (_req: Request, res: Response) => { +router.get('/', (_req: Request, res: Response) => { const projectRundowns = getDataProvider().getProjectRundowns(); res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); }); @@ -52,7 +55,7 @@ router.get('/', async (_req: Request, res: Response) => { /** * Returns the current rundown */ -router.get('/current', async (_req: Request, res: Response) => { +router.get('/current', (_req: Request, res: Response) => { const rundown = getCurrentRundown(); res.json(rundown); }); @@ -60,7 +63,7 @@ router.get('/current', async (_req: Request, res: Response) => { /** * Returns a given rundown in its normalised client shape */ -router.get('/:id', paramsWithId, async (req: Request, res: Response) => { +router.get('/:id', paramsWithId, (req: Request, res: Response) => { try { const rundown = getProcessedRundown(req.params.id); res.json(rundown); @@ -104,13 +107,7 @@ router.post( paramsWithId, 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(); + const projectRundowns = await duplicateRundown(req.params.id); res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); } catch (error) { const message = getErrorMessage(error); @@ -123,54 +120,26 @@ router.post( * Patches the data of an existing rundown * Currently only the title can be changed */ -router.patch('/:id', paramsWithId, async (req: Request, res: Response) => { - try { - const dataProvider = getDataProvider(); - const rundown = dataProvider.getRundown(req.params.id); - if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`); - if (!req.body.title) throw new Error('No title provided'); - - await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title }); - - /** - * If loaded we re-init the rundown - * This is likely over-kill but the simplest way to ensure state consistency - */ - if (req.params.id === getCurrentRundown().id) { - const rundown = dataProvider.getRundown(req.params.id); - const customField = dataProvider.getCustomFields(); - await initRundown(rundown, customField); +router.patch( + '/:id', + rundownPatchValidator, + async (req: Request, res: Response) => { + try { + const projectRundowns = await renameRundown(req.params.id, req.body.title); + res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); } - - 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 */ router.delete('/:id', paramsWithId, async (req: Request, res: Response) => { try { - if (req.params.id === getCurrentRundown().id) { - res.status(400).send({ message: 'Cannot delete loaded rundown' }); - return; - } - - const dataProvider = getDataProvider(); - const projectRundowns = dataProvider.getProjectRundowns(); - - if (Object.keys(projectRundowns).length <= 1) { - // might never hit this as it is likely covered by the case of trying to delete the loaded rundown - res.status(400).send({ message: 'Cannot delete the last rundown' }); - return; - } - - await dataProvider.deleteRundown(req.params.id); - const newProjectRundowns = getDataProvider().getProjectRundowns(); + const newProjectRundowns = await deleteRundown(req.params.id); res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) }); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 07c03ab26..470acea19 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -16,7 +16,7 @@ import { isOntimeEvent, isOntimeGroup, } from 'ontime-types'; -import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils'; +import { customFieldLabelToKey, generateId, getInsertAfterId, resolveInsertParent } from 'ontime-utils'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; @@ -643,7 +643,7 @@ export function isCurrentRundown(id: string) { /** * @throws if the provided id does not exist */ -export async function loadRundown(id: string) { +export function loadRundown(id: string) { const dataProvider = getDataProvider(); if (isCurrentRundown(id)) { return dataProvider.getProjectRundowns(); @@ -651,7 +651,7 @@ export async function loadRundown(id: string) { const rundown = dataProvider.getRundown(id); const customField = dataProvider.getCustomFields(); - await initRundown(rundown, customField); + initRundown(rundown, customField); return dataProvider.getProjectRundowns(); } @@ -659,11 +659,7 @@ export async function loadRundown(id: string) { * Sets a new rundown in the cache * and marks it as the currently loaded one */ -export async function initRundown( - rundown: Readonly, - customFields: Readonly, - reload: boolean = false, -) { +export function initRundown(rundown: Readonly, customFields: Readonly, reload: boolean = false) { runtimeService.stop(); const { rundownMetadata, revision } = rundownCache.init(rundown, customFields); logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`); @@ -692,3 +688,73 @@ export async function createNewRundown(title: string) { return projectRundowns; } + +/** + * duplicate a rundown + * @throws + */ +export async function duplicateRundown(id: string) { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + + const newRundownId = generateId(); + const newRundown: Rundown = structuredClone(rundown); + newRundown.id = newRundownId; + newRundown.title = `Copy of ${rundown.title}`; + newRundown.revision = 0; + + const newProjectRundowns = await dataProvider.setRundown(newRundownId, newRundown); + + setImmediate(() => { + sendRefetch(RefetchKey.ProjectRundowns); + }); + + return newProjectRundowns; +} + +/** + * rename a rundown + * @throws + */ +export async function renameRundown(id: string, title: string) { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + const newProjectRundowns = await dataProvider.setRundown(rundown.id, { ...rundown, title }); + + /** + * If we are modifying the loaded rundown we re-init it + * This is likely over-kill but the simplest way to ensure state consistency + */ + if (isCurrentRundown(id)) { + const rundown = dataProvider.getRundown(id); + const customField = dataProvider.getCustomFields(); + initRundown(rundown, customField); + } else { + setImmediate(() => { + sendRefetch(RefetchKey.ProjectRundowns); + }); + } + + return newProjectRundowns; +} + +/** + * delete a rundown + * @throws + */ +export async function deleteRundown(id: string) { + if (isCurrentRundown(id)) throw new Error('Cannot delete loaded rundown'); + + const dataProvider = getDataProvider(); + const projectRundowns = dataProvider.getProjectRundowns(); + + // might never hit this as it is likely covered by the case of trying to delete the loaded rundown + if (Object.keys(projectRundowns).length <= 1) throw new Error('Cannot delete the last rundown'); + const newProjectRundowns = await dataProvider.deleteRundown(id); + + setImmediate(() => { + sendRefetch(RefetchKey.ProjectRundowns); + }); + + return newProjectRundowns; +} diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 740f56cba..f796b3a47 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -460,20 +460,6 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund }); } -/** - * 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; -} - export type IncrementNumber = { integer: number; faction: number; diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 67b292279..1ef62aaed 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -5,6 +5,11 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio // #region operations on project rundowns ========================= export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction]; +export const rundownPatchValidator = [ + param('id').isString().trim().notEmpty(), + body('title').isString().trim().notEmpty().withMessage('No title provided'), + requestValidationFunction, +]; // #endregion operations on project rundowns ====================== // #region operations on rundown entries ========================== diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 50e10e422..44cc2aff6 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -101,9 +101,10 @@ function getCustomFields(): Readonly { return db.data.customFields; } -async function setRundown(rundownKey: string, newData: Rundown): Promise { +async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { db.data.rundowns[rundownKey] = structuredClone(newData); await persist(); + return db.data.rundowns; } function getSettings(): Readonly { diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index 46d118b87..1ad9227b6 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -30,6 +30,8 @@ test('cuesheet datagrid does not submit timer cells on tab-out or escape', async // re-enter edit mode: original value should be unchanged await durationCell.click(); + // tabbing selects the next input field so we have to click twice to first leave input field and then select + await durationCell.click(); await expect(durationCell.locator('input')).toHaveValue(originalDuration); await durationCell.locator('input').press('Escape');