fix: send refetch on all rundown edits (#2098)

* fix(server): send refetch on all rundown edits

* fix(test): cuesheet tabing

* chore: remove unneeded async

* refactor: small cleanups

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* chore: remove unneded async/await

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
Alex Christoffer Rasmussen
2026-06-20 12:21:11 +02:00
committed by GitHub
parent 5dbe7afae8
commit 41a09bf5c3
7 changed files with 105 additions and 97 deletions
@@ -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'] });
@@ -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<ProjectRundownsList>) => {
router.get('/', (_req: Request, res: Response<ProjectRundownsList>) => {
const projectRundowns = getDataProvider().getProjectRundowns();
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
});
@@ -52,7 +55,7 @@ router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
/**
* Returns the current rundown
*/
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
router.get('/current', (_req: Request, res: Response<Rundown>) => {
const rundown = getCurrentRundown();
res.json(rundown);
});
@@ -60,7 +63,7 @@ router.get('/current', async (_req: Request, res: Response<Rundown>) => {
/**
* Returns a given rundown in its normalised client shape
*/
router.get('/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
router.get('/:id', paramsWithId, (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = getProcessedRundown(req.params.id);
res.json(rundown);
@@ -104,13 +107,7 @@ router.post(
paramsWithId,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
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<ProjectRundownsList | ErrorResponse>) => {
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<ProjectRundownsList | ErrorResponse>) => {
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<ProjectRundownsList | ErrorResponse>) => {
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);
@@ -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<Rundown>,
customFields: Readonly<CustomFields>,
reload: boolean = false,
) {
export function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>, 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;
}
@@ -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;
@@ -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 ==========================
@@ -101,9 +101,10 @@ function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(rundownKey: string, newData: Rundown): Promise<void> {
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
db.data.rundowns[rundownKey] = structuredClone(newData);
await persist();
return db.data.rundowns;
}
function getSettings(): Readonly<Settings> {
+2
View File
@@ -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');