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 ebd4a5c92..c563466d6 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 @@ -1,4 +1,4 @@ -import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeGroup } from 'ontime-types'; +import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { assertType } from 'vitest'; @@ -11,7 +11,6 @@ import { deleteById, doesInvalidateMetadata, duplicateRundown, - getInsertAfterId, hasChanges, makeDeepClone, } from '../rundown.utils.js'; @@ -226,45 +225,6 @@ describe('calculateDayOffset()', () => { }); }); -describe('getInsertAfterId()', () => { - const rundown = makeRundown({ - entries: { - '1': makeOntimeEvent({ id: '1', parent: null }), - '2': makeOntimeEvent({ id: '2', parent: null }), - group: makeOntimeGroup({ id: 'group', entries: ['31', '32'] }), - '31': makeOntimeEvent({ id: '31', parent: 'group' }), - '32': makeOntimeEvent({ id: '32', parent: 'group' }), - '4': makeOntimeEvent({ id: '4', parent: null }), - }, - order: ['1', '2', 'group', '4'], - flatOrder: ['1', '2', 'group', '31', '32', '4'], - }); - - it('returns afterId if provided', () => { - expect(getInsertAfterId(rundown, null, 'b')).toBe('b'); - }); - - it('returns null if neither afterId nor beforeId is provided', () => { - expect(getInsertAfterId(rundown, null)).toBeNull(); - }); - - it('returns null if beforeId is not found', () => { - expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull(); - expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull(); - }); - - it('returns the previous id of an entry in the rundown', () => { - expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1'); - expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group'); - expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2'); - }); - - it('returns the previous id of an event in a group', () => { - expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull(); - expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31'); - }); -}); - describe('duplicateRundown', () => { it('duplicates a given rundown', () => { const demoRundown = demoDb.rundowns['default']; diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 323add503..0cea576d5 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -26,7 +26,7 @@ import { Rundown, InsertOptions, } from 'ontime-types'; -import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils'; +import { addToRundown, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { consoleError } from '../../utils/console.js'; @@ -38,7 +38,6 @@ import { createGroup, deleteById, doesInvalidateMetadata, - getInsertAfterId, getUniqueId, makeDeepClone, } from './rundown.utils.js'; @@ -163,50 +162,6 @@ export function createTransaction(options: TransactionOptions): Transaction { }; } -/** - * Add entry to rundown, handles the following cases: - * - 1a. add entry in group, after a given entry - * - 1b. add entry in group, at the beginning - * - 2a. add entry to the rundown, after a given entry - * - 2b. add entry to the rundown, at the beginning - */ -function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry { - if (parent) { - // 1. inserting an entry inside a group - - if ('parent' in entry) { - entry.parent = parent.id; - } - - if (afterId) { - const atEventsIndex = parent.entries.indexOf(afterId) + 1; - const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; - parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries); - rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); - } else { - parent.entries = insertAtIndex(0, entry.id, parent.entries); - const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1; - rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); - } - } else { - // 2. inserting an entry at top level - if (afterId) { - const atOrderIndex = rundown.order.indexOf(afterId) + 1; - const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; - rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order); - rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); - } else { - rundown.order = insertAtIndex(0, entry.id, rundown.order); - rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder); - } - } - - // either way, we insert the entry into the rundown - rundown.entries[entry.id] = entry; - - return entry; -} - /** * Applies a patch of changes to an existing entry * @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache @@ -516,7 +471,7 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O after = entry.id; } - return add(rundown, clonedEntry, after, parent); + return addToRundown(rundown, clonedEntry, after, parent); } } @@ -583,7 +538,7 @@ function ungroup(rundown: Rundown, group: OntimeGroup) { } export const rundownMutation = { - add, + add: addToRundown, edit, remove, removeAll, diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index f87cc629c..5ad4fb562 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 { ProjectRundowns, InsertOptions, } from 'ontime-types'; -import { customFieldLabelToKey } from 'ontime-utils'; +import { customFieldLabelToKey, getInsertAfterId } from 'ontime-utils'; import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../../services/runtime-service/runtime.service.js'; @@ -33,7 +33,7 @@ import { updateBackgroundRundown, } from './rundown.dao.js'; import type { RundownMetadata } from './rundown.types.js'; -import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js'; +import { generateEvent, hasChanges } from './rundown.utils.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; /** diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 4a894439e..2945a3b84 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -483,31 +483,6 @@ export function calculateDayOffset( return 0; } -/** - * Receives an insertion order and returns the reference to an event ID - * after which we will insert the new event - */ -export function getInsertAfterId( - rundown: Rundown, - parent: OntimeGroup | null, - afterId?: EntryId, - beforeId?: EntryId, -): EntryId | null { - if (afterId) return afterId; - if (!beforeId) return null; - - /** - * At this point we know we want to insert before a given ID - * We need to check which list we should use to insert and find the event there - */ - const insertionList = parent ? parent.entries : rundown.order; - if (!insertionList || insertionList.length === 0) return null; - - const atIndex = insertionList.findIndex((id) => id === beforeId); - if (atIndex < 1) return null; - return insertionList[atIndex - 1]; -} - /** * Sanitises custom fields in an entry by removing fields * - if it does not exist in the project diff --git a/packages/utils/index.ts b/packages/utils/index.ts index c8a25176a..227a0fb53 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,11 +8,13 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { generateId } from './src/generate-id/generateId.js'; export { + addToRundown, getEventWithId, getFirstEvent, getFirstEventNormal, getFirstNormal, getFirstGroupNormal, + getInsertAfterId, getLastEvent, getLastEventNormal, getLastNormal, diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index 02d9bfb9b..ad65f4958 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -2,7 +2,9 @@ import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup, Rundown } from import { SupportedEntry } from 'ontime-types'; import { + addToRundown, getFirstGroupNormal, + getInsertAfterId, getLastEvent, getLastGroupNormal, getLastNormal, @@ -226,12 +228,126 @@ describe('getLastEvent', () => { expect(lastEntry).toBe(null); }); }); +}); - entries: { - }, +describe('getInsertAfterId()', () => { + const rundown = { + id: 'test', + title: 'test', + entries: { + '1': { id: '1', type: SupportedEntry.Event, parent: null } as OntimeEvent, + '2': { id: '2', type: SupportedEntry.Event, parent: null } as OntimeEvent, + group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup, + '31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent, + '32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent, + '4': { id: '4', type: SupportedEntry.Event, parent: null } as OntimeEvent, + }, + order: ['1', '2', 'group', '4'], + flatOrder: ['1', '2', 'group', '31', '32', '4'], + revision: 1, + } as Rundown; + it('returns afterId if provided', () => { + expect(getInsertAfterId(rundown, null, 'b')).toBe('b'); + }); + it('returns null if neither afterId nor beforeId is provided', () => { + expect(getInsertAfterId(rundown, null)).toBeNull(); + }); + it('returns null if beforeId is not found', () => { + expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull(); + expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull(); + }); + it('returns the previous id of an entry in the rundown', () => { + expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1'); + expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group'); + expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2'); + }); + + it('returns the previous id of an event in a group', () => { + expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull(); + expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31'); + }); +}); + +describe('addToRundown()', () => { + const makeTestRundown = (): Rundown => + ({ + id: 'test', + title: 'test', + entries: { + '1': { id: '1', type: SupportedEntry.Event, parent: null } as OntimeEvent, + '2': { id: '2', type: SupportedEntry.Event, parent: null } as OntimeEvent, + group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup, + '31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent, + '32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent, + }, + order: ['1', '2', 'group'], + flatOrder: ['1', '2', 'group', '31', '32'], + revision: 1, + }) as Rundown; + + it('adds an entry to an empty rundown', () => { + const rundown = { id: 'test', title: '', entries: {}, order: [], flatOrder: [], revision: 0 } as Rundown; + const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent; + + addToRundown(rundown, newEntry, null, null); + + expect(rundown.order).toEqual(['new']); + expect(rundown.flatOrder).toEqual(['new']); + expect(rundown.entries['new']).toBe(newEntry); + }); + + // case 2b: insert at the beginning of the rundown + it('adds at the beginning of order and flatOrder when afterId is null', () => { + const rundown = makeTestRundown(); + const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent; + + addToRundown(rundown, newEntry, null, null); + + expect(rundown.order).toEqual(['new', '1', '2', 'group']); + expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']); + }); + + // case 2a: insert after a given entry at top level + it('inserts after the referenced entry in both order and flatOrder', () => { + const rundown = makeTestRundown(); + const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent; + + addToRundown(rundown, newEntry, '1', null); + + expect(rundown.order).toEqual(['1', 'new', '2', 'group']); + expect(rundown.flatOrder).toEqual(['1', 'new', '2', 'group', '31', '32']); + }); + + // case 1b: insert at the beginning of a group + it('inserts right after the group header in flatOrder and sets parent', () => { + const rundown = makeTestRundown(); + const parent = rundown.entries['group'] as OntimeGroup; + const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent; + + addToRundown(rundown, newEntry, null, parent); + + expect(parent.entries).toEqual(['new', '31', '32']); + expect(newEntry.parent).toBe('group'); + expect(rundown.flatOrder).toEqual(['1', '2', 'group', 'new', '31', '32']); + // top-level order must not change when inserting into a group + expect(rundown.order).toEqual(['1', '2', 'group']); + }); + + // case 1a: insert after a given entry within a group + it('inserts after the referenced entry within the group and in flatOrder', () => { + const rundown = makeTestRundown(); + const parent = rundown.entries['group'] as OntimeGroup; + const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent; + + addToRundown(rundown, newEntry, '31', parent); + + expect(parent.entries).toEqual(['31', 'new', '32']); + expect(newEntry.parent).toBe('group'); + expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', 'new', '32']); + expect(rundown.order).toEqual(['1', '2', 'group']); }); }); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index 9f70beadb..9ee86de8e 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -9,6 +9,8 @@ import type { } from 'ontime-types'; import { isOntimeEvent, isOntimeGroup, isPlayableEvent } from 'ontime-types'; +import { insertAtIndex } from '../common/arrayUtils.js'; + type IndexAndEntry = { entry: OntimeEntry | null; index: number | null }; type GroupIndexAndEntry = { entry: OntimeGroup | null; index: number | null }; @@ -360,11 +362,76 @@ export function getNextGroupNormal( } /** - * Gets relevant group element for a given ID + * Receives an insertion order and returns the reference to an entry ID + * after which we will insert the new entry */ +export function getInsertAfterId( + rundown: Rundown, + parent: OntimeGroup | null, + afterId?: EntryId, + beforeId?: EntryId, +): EntryId | null { + if (afterId) return afterId; + if (!beforeId) return null; + const insertionList = parent ? parent.entries : rundown.order; + if (!insertionList || insertionList.length === 0) return null; + const atIndex = insertionList.findIndex((id) => id === beforeId); + if (atIndex < 1) return null; + return insertionList[atIndex - 1]; +} + +/** + * Add entry to rundown, mutates the rundown in place. + * Handles the following cases: + * - 1a. add entry in group, after a given entry + * - 1b. add entry in group, at the beginning (right after the group header) + * - 2a. add entry to the rundown, after a given entry + * - 2b. add entry to the rundown, at the beginning + */ +export function addToRundown( + rundown: Rundown, + entry: OntimeEntry, + afterId: EntryId | null, + parent: OntimeGroup | null, +): OntimeEntry { + if (parent) { + // 1. inserting an entry inside a group + + // assign the parent reference on the entry + if ('parent' in entry) { + entry.parent = parent.id; } + + if (afterId) { + // 1a. insert after a given entry within the group + const atEventsIndex = parent.entries.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + // 1b. insert at the beginning of the group (right after the group header in flatOrder) + parent.entries = insertAtIndex(0, entry.id, parent.entries); + const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1; + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } + } else { + // 2. inserting an entry at top level + if (afterId) { + // 2a. insert after a given entry + const atOrderIndex = rundown.order.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + // 2b. insert at the beginning + rundown.order = insertAtIndex(0, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder); } } + + // either way, we register the entry in the entries map + rundown.entries[entry.id] = entry; + return entry; }