mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
refactor: extract rundown parsing
refactor: implement groups in editor
This commit is contained in:
@@ -5,8 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^2.7.0",
|
"@chakra-ui/react": "^2.7.0",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/is-prop-valid": "^1.3.1",
|
"@emotion/is-prop-valid": "^1.3.1",
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
|
|||||||
@@ -138,16 +138,14 @@ export const useEntryActions = () => {
|
|||||||
|
|
||||||
// handle adding options that concern all event type
|
// handle adding options that concern all event type
|
||||||
if (options?.after) {
|
if (options?.after) {
|
||||||
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
(newEntry as TransientEventPayload).after = options.after;
|
||||||
newEntry.after = options.after;
|
|
||||||
}
|
}
|
||||||
if (options?.before) {
|
if (options?.before) {
|
||||||
// @ts-expect-error -- not sure how to type this, <before> is a transient property
|
(newEntry as TransientEventPayload).before = options.before;
|
||||||
newEntry.before = options.before;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _addEntryMutation.mutateAsync(newEntry as TransientEventPayload);
|
await _addEntryMutation.mutateAsync(newEntry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { IoAdd } from 'react-icons/io5';
|
|
||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import style from './Empty.module.scss';
|
|
||||||
|
|
||||||
interface BlockEmptyProps {
|
|
||||||
handleAddNew: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlockEmpty(props: BlockEmptyProps) {
|
|
||||||
const { handleAddNew } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.empty}>
|
|
||||||
<Button size='sm' onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
|
|
||||||
Create Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entryIndex {
|
.entryIndex {
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useHotkeys } from '@mantine/hooks';
|
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
type EntryId,
|
type EntryId,
|
||||||
type MaybeString,
|
type MaybeString,
|
||||||
type Rundown,
|
type Rundown,
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
|
OntimeBlock,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
Playback,
|
Playback,
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
@@ -30,9 +40,9 @@ import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
|||||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||||
|
|
||||||
import BlockBlock from './block-block/BlockBlock';
|
import BlockBlock from './block-block/BlockBlock';
|
||||||
|
import BlockEnd from './block-block/BlockEnd';
|
||||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||||
import BlockEmpty from './BlockEmpty';
|
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
|
||||||
import { makeRundownMetadata } from './rundown.utils';
|
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
@@ -45,10 +55,16 @@ interface RundownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Rundown({ data }: RundownProps) {
|
export default function Rundown({ data }: RundownProps) {
|
||||||
const { order, entries } = data;
|
const { order, flatOrder, entries, id } = data;
|
||||||
const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
|
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
||||||
|
|
||||||
const featureData = useRundownEditor();
|
const featureData = useRundownEditor();
|
||||||
|
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(flatOrder, entries));
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||||
|
// we ensure that this is unique to the rundown
|
||||||
|
key: `rundown.${id}-editor-collapsed-groups`,
|
||||||
|
defaultValue: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
|
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
|
||||||
|
|
||||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||||
@@ -221,11 +237,11 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
// we copy the state from the store here
|
// we copy the state from the store here
|
||||||
// to workaround async updates on the drag mutations
|
// to workaround async updates on the drag mutations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStatefulEntries(order);
|
setSortableData(makeSortableList(flatOrder, entries));
|
||||||
}, [order]);
|
}, [flatOrder, entries]);
|
||||||
|
|
||||||
|
// in run mode, we follow selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// in run mode, we follow selection
|
|
||||||
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -233,6 +249,36 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
||||||
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
|
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a block is collapsed
|
||||||
|
*/
|
||||||
|
const getIsCollapsed = useCallback(
|
||||||
|
(blockId: EntryId): boolean => {
|
||||||
|
return Boolean(collapsedGroups.find((id) => id === blockId));
|
||||||
|
},
|
||||||
|
[collapsedGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles logic for collapsing groups
|
||||||
|
*/
|
||||||
|
const handleCollapseGroup = useCallback(
|
||||||
|
(collapsed: boolean, groupId: EntryId) => {
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
const isCollapsed = getIsCollapsed(groupId);
|
||||||
|
if (collapsed && !isCollapsed) {
|
||||||
|
const newSet = new Set(prev).add(groupId);
|
||||||
|
return [...newSet];
|
||||||
|
}
|
||||||
|
if (!collapsed && isCollapsed) {
|
||||||
|
return [...prev].filter((id) => id !== groupId);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getIsCollapsed, setCollapsedGroups],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On drag end, we reorder the events
|
* On drag end, we reorder the events
|
||||||
*/
|
*/
|
||||||
@@ -245,7 +291,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
const toIndex = over.data.current?.sortable.index;
|
const toIndex = over.data.current?.sortable.index;
|
||||||
|
|
||||||
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
||||||
setStatefulEntries((currentEntries) => {
|
setSortableData((currentEntries) => {
|
||||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||||
});
|
});
|
||||||
reorderEntry(String(active.id), fromIndex, toIndex);
|
reorderEntry(String(active.id), fromIndex, toIndex);
|
||||||
@@ -253,7 +299,36 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (statefulEntries.length < 1) {
|
/**
|
||||||
|
* When we drag a block, we force collapse it
|
||||||
|
* This avoids strange scenarios like dropping a block inside itself
|
||||||
|
*/
|
||||||
|
const collapseDraggedBlocks = (event: DragStartEvent) => {
|
||||||
|
const isBlock = event.active.data.current?.type === 'block';
|
||||||
|
if (isBlock) {
|
||||||
|
handleCollapseGroup(true, event.active.id as EntryId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When we drag over a block, we expand it if it is collapsed
|
||||||
|
*/
|
||||||
|
const expandOverBlock = (event: DragOverEvent) => {
|
||||||
|
// if we are dragging a block, the drop operation is invalid so we dont expand
|
||||||
|
if (event.active.data.current?.type === 'block') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.over?.data.current?.type !== 'block') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blockId = event.over?.id as EntryId;
|
||||||
|
const isCollapsed = getIsCollapsed(blockId);
|
||||||
|
if (isCollapsed) {
|
||||||
|
handleCollapseGroup(false, blockId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sortableData.length < 1) {
|
||||||
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
|
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,121 +336,130 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
const isEditMode = appMode === AppMode.Edit;
|
const isEditMode = appMode === AppMode.Edit;
|
||||||
|
|
||||||
// 2. initialise rundown metadata
|
// 2. initialise rundown metadata
|
||||||
const process = makeRundownMetadata(featureData?.selectedEventId);
|
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
|
||||||
|
// keep a single reference to the metadata which we override for every entry
|
||||||
|
let rundownMetadata = metadata;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
<DndContext
|
||||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
onDragEnd={handleOnDragEnd}
|
||||||
|
onDragStart={collapseDraggedBlocks}
|
||||||
|
onDragOver={expandOverBlock}
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
>
|
||||||
|
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
||||||
<div className={style.list}>
|
<div className={style.list}>
|
||||||
{statefulEntries.map((entryId, index) => {
|
{sortableData.map((entryId, index) => {
|
||||||
// we iterate through a stateful copy of order to make the operations smoother
|
const isFirst = index === 0;
|
||||||
|
const isLast = index === sortableData.length - 1;
|
||||||
|
|
||||||
|
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
|
||||||
|
if (entryId.startsWith('end-')) {
|
||||||
|
const parentId = entryId.split('end-')[1];
|
||||||
|
const isBlockCollapsed = getIsCollapsed(parentId);
|
||||||
|
|
||||||
|
if (isBlockCollapsed && isEditMode && isLast) {
|
||||||
|
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
|
||||||
|
} else {
|
||||||
|
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
|
||||||
|
// if the previous element is selected, it will have its own QuickAddBlock
|
||||||
|
// we use thisId instead of previousEntryId because the block end does not process
|
||||||
|
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||||
|
const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
|
||||||
|
return (
|
||||||
|
<Fragment key={entryId}>
|
||||||
|
{showPrependingQuickAdd && (
|
||||||
|
<QuickAddBlock
|
||||||
|
previousEventId={rundownMetadata.previousEntryId}
|
||||||
|
parentBlock={parentId}
|
||||||
|
backgroundColor={parentColour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
|
||||||
|
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we iterate through a stateful copy of order to make the dnd operations smoother
|
||||||
// this means that this can be out of sync with order until the useEffect runs
|
// this means that this can be out of sync with order until the useEffect runs
|
||||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
// instead of writing all the logic guards, we simply short circuit rendering here
|
||||||
const entry = entries[entryId];
|
const entry = entries[entryId];
|
||||||
if (!entry) {
|
if (!entry) return null;
|
||||||
|
rundownMetadata = process(entry);
|
||||||
|
|
||||||
|
// if the entry has a parent, and it is collapsed, render nothing
|
||||||
|
if (
|
||||||
|
entry.type !== SupportedEntry.Block &&
|
||||||
|
rundownMetadata.groupId !== null &&
|
||||||
|
getIsCollapsed(rundownMetadata.groupId)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rundownMeta = process(entry);
|
|
||||||
const isFirst = index === 0;
|
|
||||||
const isLast = index === order.length - 1;
|
|
||||||
const isNext = featureData?.nextEventId === entry.id;
|
const isNext = featureData?.nextEventId === entry.id;
|
||||||
const hasCursor = entry.id === cursor;
|
const hasCursor = entry.id === cursor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outside a block, the value will be undefined
|
||||||
|
* If the colour is empty string ''
|
||||||
|
* ie: we are inside a block, but there is no defined colour
|
||||||
|
* we default to $gray-1050 #303030
|
||||||
|
*/
|
||||||
|
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
{isEditMode && (hasCursor || isFirst) && (
|
{isEditMode && (hasCursor || isFirst) && (
|
||||||
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} parentBlock={null} />
|
<QuickAddBlock
|
||||||
|
previousEventId={rundownMetadata.previousEntryId}
|
||||||
|
parentBlock={isFirst ? null : rundownMetadata.groupId}
|
||||||
|
backgroundColor={isFirst ? undefined : blockColour}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{isOntimeBlock(entry) ? (
|
{isOntimeBlock(entry) ? (
|
||||||
<BlockBlock data={entry} hasCursor={hasCursor}>
|
<BlockBlock
|
||||||
{entry.events.length === 0 && (
|
data={entry}
|
||||||
<BlockEmpty
|
hasCursor={hasCursor}
|
||||||
handleAddNew={() => insertAtId({ type: SupportedEntry.Event, parent: entry.id }, entry.id)}
|
collapsed={getIsCollapsed(entry.id)}
|
||||||
/>
|
onCollapse={handleCollapseGroup}
|
||||||
)}
|
/>
|
||||||
{entry.events.map((eventId, nestedIndex) => {
|
|
||||||
const nestedEntry = entries[eventId];
|
|
||||||
if (!nestedEntry) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestedRundownMeta = process(nestedEntry);
|
|
||||||
const isFirstInGroup = nestedIndex === 0;
|
|
||||||
const isLastInGroup = nestedIndex === entry.events.length - 1;
|
|
||||||
const hasNestedCursor = nestedEntry.id === cursor;
|
|
||||||
|
|
||||||
if (!isOntimeEvent(nestedEntry)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Fragment key={nestedEntry.id}>
|
|
||||||
{isEditMode && (hasNestedCursor || isFirstInGroup) && (
|
|
||||||
<QuickAddBlock
|
|
||||||
parentBlock={entry.id}
|
|
||||||
previousEventId={nestedRundownMeta.previousEntryId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
key={nestedEntry.id}
|
|
||||||
className={style.entryWrapper}
|
|
||||||
data-testid={`entry-${nestedRundownMeta.eventIndex}`}
|
|
||||||
>
|
|
||||||
<div className={style.entryIndex}>{nestedRundownMeta.eventIndex}</div>
|
|
||||||
<div className={style.entry} ref={hasNestedCursor ? cursorRef : undefined}>
|
|
||||||
<RundownEntry
|
|
||||||
key={nestedEntry.id}
|
|
||||||
type={nestedEntry.type}
|
|
||||||
isPast={nestedRundownMeta.isPast}
|
|
||||||
eventIndex={nestedRundownMeta.eventIndex}
|
|
||||||
data={nestedEntry}
|
|
||||||
loaded={nestedRundownMeta.isLoaded}
|
|
||||||
hasCursor={hasNestedCursor}
|
|
||||||
isNext={isNext}
|
|
||||||
previousEntryId={nestedRundownMeta.previousEntryId}
|
|
||||||
previousEventId={nestedRundownMeta.previousEvent?.id}
|
|
||||||
playback={nestedRundownMeta.isLoaded ? featureData.playback : undefined}
|
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
|
||||||
isNextDay={nestedRundownMeta.isNextDay}
|
|
||||||
totalGap={nestedRundownMeta.totalGap}
|
|
||||||
isLinkedToLoaded={nestedRundownMeta.isLinkedToLoaded}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isEditMode && (hasNestedCursor || isLastInGroup) && (
|
|
||||||
<QuickAddBlock parentBlock={entry.id} previousEventId={nestedEntry.id} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</BlockBlock>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={style.entryWrapper} data-testid={`entry-${rundownMeta.eventIndex}`}>
|
<div
|
||||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMeta.eventIndex}</div>}
|
className={style.entryWrapper}
|
||||||
|
data-testid={`entry-${rundownMetadata.eventIndex}`}
|
||||||
|
style={blockColour ? { '--user-bg': blockColour } : {}}
|
||||||
|
>
|
||||||
|
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
|
||||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||||
<RundownEntry
|
<RundownEntry
|
||||||
type={entry.type}
|
type={entry.type}
|
||||||
isPast={rundownMeta.isPast}
|
isPast={rundownMetadata.isPast}
|
||||||
eventIndex={rundownMeta.eventIndex}
|
eventIndex={rundownMetadata.eventIndex}
|
||||||
data={entry}
|
data={entry}
|
||||||
loaded={rundownMeta.isLoaded}
|
loaded={rundownMetadata.isLoaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
previousEntryId={rundownMeta.previousEntryId}
|
previousEntryId={rundownMetadata.previousEntryId}
|
||||||
previousEventId={rundownMeta.previousEvent?.id}
|
previousEventId={rundownMetadata.previousEvent?.id}
|
||||||
playback={rundownMeta.isLoaded ? featureData.playback : undefined}
|
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
isNextDay={rundownMeta.isNextDay}
|
isNextDay={rundownMetadata.isNextDay}
|
||||||
totalGap={rundownMeta.totalGap}
|
totalGap={rundownMetadata.totalGap}
|
||||||
isLinkedToLoaded={rundownMeta.isLinkedToLoaded}
|
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEditMode && (hasCursor || isLast) && (
|
{isEditMode && (hasCursor || isLast) && (
|
||||||
<QuickAddBlock previousEventId={entry.id} parentBlock={null} />
|
<QuickAddBlock
|
||||||
|
previousEventId={entry.id}
|
||||||
|
parentBlock={rundownMetadata.groupId}
|
||||||
|
backgroundColor={blockColour}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
skip={data.skip}
|
skip={data.skip}
|
||||||
|
parent={data.parent}
|
||||||
loaded={loaded}
|
loaded={loaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OntimeBlock, OntimeEvent, SupportedEntry } from 'ontime-types';
|
import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { makeRundownMetadata } from '../rundown.utils';
|
import { makeRundownMetadata, makeSortableList } from '../rundown.utils';
|
||||||
|
|
||||||
describe('makeRundownMetadata()', () => {
|
describe('makeRundownMetadata()', () => {
|
||||||
it('processes nested rundown data', () => {
|
it('processes nested rundown data', () => {
|
||||||
@@ -21,7 +21,8 @@ describe('makeRundownMetadata()', () => {
|
|||||||
block: {
|
block: {
|
||||||
id: 'block',
|
id: 'block',
|
||||||
type: SupportedEntry.Block,
|
type: SupportedEntry.Block,
|
||||||
events: ['11, 12, 13'],
|
events: ['11', '12', '13'],
|
||||||
|
colour: 'red',
|
||||||
} as OntimeBlock,
|
} as OntimeBlock,
|
||||||
'11': {
|
'11': {
|
||||||
id: '11',
|
id: '11',
|
||||||
@@ -73,7 +74,22 @@ describe('makeRundownMetadata()', () => {
|
|||||||
} as OntimeEvent,
|
} as OntimeEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const process = makeRundownMetadata(selectedEventId);
|
const { metadata, process } = makeRundownMetadata(selectedEventId);
|
||||||
|
|
||||||
|
expect(metadata).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: null,
|
||||||
|
previousEntryId: null,
|
||||||
|
thisId: null,
|
||||||
|
eventIndex: 0,
|
||||||
|
isPast: true,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
expect(process(demoEvents['1'])).toStrictEqual({
|
expect(process(demoEvents['1'])).toStrictEqual({
|
||||||
previousEvent: null,
|
previousEvent: null,
|
||||||
@@ -82,11 +98,12 @@ describe('makeRundownMetadata()', () => {
|
|||||||
thisId: demoEvents['1'].id,
|
thisId: demoEvents['1'].id,
|
||||||
eventIndex: 1, // UI indexes are 1 based
|
eventIndex: 1, // UI indexes are 1 based
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(process(demoEvents['block'])).toMatchObject({
|
expect(process(demoEvents['block'])).toMatchObject({
|
||||||
@@ -96,11 +113,12 @@ describe('makeRundownMetadata()', () => {
|
|||||||
thisId: demoEvents['block'].id,
|
thisId: demoEvents['block'].id,
|
||||||
eventIndex: 1,
|
eventIndex: 1,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
groupId: 'block',
|
||||||
|
groupColour: 'red',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(process(demoEvents['11'])).toMatchObject({
|
expect(process(demoEvents['11'])).toMatchObject({
|
||||||
@@ -110,11 +128,12 @@ describe('makeRundownMetadata()', () => {
|
|||||||
thisId: demoEvents['11'].id,
|
thisId: demoEvents['11'].id,
|
||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 10,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
groupId: 'block',
|
||||||
|
groupColour: 'red',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(process(demoEvents['12'])).toMatchObject({
|
expect(process(demoEvents['12'])).toMatchObject({
|
||||||
@@ -124,11 +143,12 @@ describe('makeRundownMetadata()', () => {
|
|||||||
thisId: demoEvents['12'].id,
|
thisId: demoEvents['12'].id,
|
||||||
eventIndex: 3,
|
eventIndex: 3,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 10,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
|
groupId: 'block',
|
||||||
|
groupColour: 'red',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(process(demoEvents['13'])).toMatchObject({
|
expect(process(demoEvents['13'])).toMatchObject({
|
||||||
@@ -138,11 +158,12 @@ describe('makeRundownMetadata()', () => {
|
|||||||
thisId: demoEvents['13'].id,
|
thisId: demoEvents['13'].id,
|
||||||
eventIndex: 4,
|
eventIndex: 4,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 10,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
groupId: 'block',
|
||||||
|
groupColour: 'red',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(process(demoEvents['2'])).toMatchObject({
|
expect(process(demoEvents['2'])).toMatchObject({
|
||||||
@@ -152,11 +173,142 @@ describe('makeRundownMetadata()', () => {
|
|||||||
thisId: demoEvents['2'].id,
|
thisId: demoEvents['2'].id,
|
||||||
eventIndex: 5,
|
eventIndex: 5,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 17,
|
totalGap: 17,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates previousEntries in blocks', () => {
|
||||||
|
const rundownStartsWithBlock = {
|
||||||
|
block: {
|
||||||
|
id: 'block',
|
||||||
|
type: SupportedEntry.Block,
|
||||||
|
colour: 'red',
|
||||||
|
events: ['1', '2'],
|
||||||
|
} as OntimeBlock,
|
||||||
|
'1': {
|
||||||
|
id: '1',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'block',
|
||||||
|
timeStart: 1,
|
||||||
|
timeEnd: 2,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
'2': {
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'block',
|
||||||
|
timeStart: 2,
|
||||||
|
timeEnd: 3,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
};
|
||||||
|
const { process } = makeRundownMetadata(null);
|
||||||
|
|
||||||
|
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: null,
|
||||||
|
previousEntryId: null,
|
||||||
|
thisId: rundownStartsWithBlock.block.id,
|
||||||
|
eventIndex: 0,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: rundownStartsWithBlock.block.id,
|
||||||
|
groupColour: 'red',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: rundownStartsWithBlock['1'],
|
||||||
|
previousEntryId: rundownStartsWithBlock.block.id,
|
||||||
|
thisId: rundownStartsWithBlock['1'].id,
|
||||||
|
eventIndex: 1,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: rundownStartsWithBlock.block.id,
|
||||||
|
groupColour: 'red',
|
||||||
|
});
|
||||||
|
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
|
||||||
|
previousEvent: rundownStartsWithBlock['1'],
|
||||||
|
latestEvent: rundownStartsWithBlock['2'],
|
||||||
|
previousEntryId: rundownStartsWithBlock['1'].id,
|
||||||
|
thisId: rundownStartsWithBlock['2'].id,
|
||||||
|
eventIndex: 2,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: rundownStartsWithBlock.block.id,
|
||||||
|
groupColour: 'red',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('makeSortableList()', () => {
|
||||||
|
it('generates a list with block ends', () => {
|
||||||
|
const flatOrder = ['block-1', '11', '2', 'block-3', '31', 'block-4'];
|
||||||
|
const entries: RundownEntries = {
|
||||||
|
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
|
||||||
|
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||||
|
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
|
||||||
|
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
|
||||||
|
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
|
||||||
|
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortableList = makeSortableList(flatOrder, entries);
|
||||||
|
expect(sortableList).toEqual([
|
||||||
|
'block-1',
|
||||||
|
'11',
|
||||||
|
'end-block-1',
|
||||||
|
'2',
|
||||||
|
'block-3',
|
||||||
|
'31',
|
||||||
|
'end-block-3',
|
||||||
|
'block-4',
|
||||||
|
'end-block-4',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dangling blocks', () => {
|
||||||
|
const flatOrder = ['block', '11', '12'];
|
||||||
|
const entries: RundownEntries = {
|
||||||
|
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
|
||||||
|
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||||
|
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortableList = makeSortableList(flatOrder, entries);
|
||||||
|
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a list with a with just blocks', () => {
|
||||||
|
const flatOrder = ['block-1', 'block-2'];
|
||||||
|
const entries: RundownEntries = {
|
||||||
|
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
|
||||||
|
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortableList = makeSortableList(flatOrder, entries);
|
||||||
|
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@use '../../theme/ontimeColours' as *;
|
@use '../../theme/ontimeColours' as *;
|
||||||
@use '../../theme/ontimeStyles' as *;
|
@use '../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
$block-width: 33rem;
|
$block-width: 32rem;
|
||||||
|
|
||||||
$block-gap: 0.25rem;
|
$block-gap: 0.25rem;
|
||||||
$block-element-spacing: 0.25rem;
|
$block-element-spacing: 0.25rem;
|
||||||
@@ -20,7 +20,6 @@ $block-cursor-color: $orange-400;
|
|||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
border: 1px solid $white-7;
|
border: 1px solid $white-7;
|
||||||
border-radius: $block-border-radius;
|
border-radius: $block-border-radius;
|
||||||
margin-block: 0.25rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
color: $block-text-color;
|
color: $block-text-color;
|
||||||
|
|
||||||
@@ -33,9 +32,11 @@ $block-cursor-color: $orange-400;
|
|||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -4,24 +4,25 @@
|
|||||||
@include block-styling;
|
@include block-styling;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
min-width: 34rem;
|
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem 1fr auto;
|
grid-template-columns: 2rem 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas: 'binder header';
|
||||||
'binder header'
|
|
||||||
'content content'
|
|
||||||
'footer footer';
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
// TODO(style fix): groups have an extra bottom margin which interrupt colour
|
||||||
|
margin-block: 0.25rem;
|
||||||
|
|
||||||
&.hasCursor {
|
&.hasCursor {
|
||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.binder {
|
.binder {
|
||||||
grid-area: binder;
|
grid-area: binder;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: $gray-1050; // to override inline
|
background-color: var(--block-color, $gray-1050);
|
||||||
color: $section-white;
|
color: $section-white;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -51,28 +52,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metaEntry {
|
.metaEntry {
|
||||||
font-size: calc(1rem - 3px);
|
|
||||||
width: 4.5em;
|
width: 4.5em;
|
||||||
|
|
||||||
:first-child {
|
:first-child {
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
|
||||||
background-color: color-mix(in srgb, var(--user-bg, $gray-1050) 10%, transparent 90%);
|
|
||||||
grid-area: content;
|
|
||||||
padding-right: 2px;
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
grid-area: footer;
|
|
||||||
background-color: var(--user-bg, $gray-1050) ;
|
|
||||||
height: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag {
|
.drag {
|
||||||
@include drag-style;
|
@include drag-style;
|
||||||
|
|
||||||
|
&.isDragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.notAllowed {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { PropsWithChildren, useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
|
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
import { EntryId, OntimeBlock } from 'ontime-types';
|
||||||
import { OntimeBlock } from 'ontime-types';
|
|
||||||
|
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||||
|
import { canDrop } from '../rundown.utils';
|
||||||
|
|
||||||
import style from './BlockBlock.module.scss';
|
import style from './BlockBlock.module.scss';
|
||||||
|
|
||||||
interface BlockBlockProps {
|
interface BlockBlockProps {
|
||||||
data: OntimeBlock;
|
data: OntimeBlock;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
export default function BlockBlock(props: BlockBlockProps) {
|
||||||
const { data, hasCursor, children } = props;
|
const { data, hasCursor, collapsed, onCollapse } = props;
|
||||||
const [collapsed, setCollapsed] = useSessionStorage<boolean>({ key: `block-${data.id}`, defaultValue: false });
|
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -27,21 +28,30 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
|||||||
setNodeRef,
|
setNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
|
isDragging,
|
||||||
|
isOver,
|
||||||
|
over,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
data: {
|
||||||
|
type: 'block',
|
||||||
|
},
|
||||||
animateLayoutChanges: () => false,
|
animateLayoutChanges: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||||
|
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||||
|
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
|
zIndex: isDragging ? 2 : 'inherit',
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
|
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx([style.block, hasCursor && style.hasCursor])}
|
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
|
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
|
||||||
@@ -49,14 +59,19 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
<span
|
||||||
|
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
|
||||||
|
ref={handleRef}
|
||||||
|
{...dragAttributes}
|
||||||
|
{...dragListeners}
|
||||||
|
>
|
||||||
<IoReorderTwo />
|
<IoReorderTwo />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<div className={style.titleRow}>
|
<div className={style.titleRow}>
|
||||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||||
<button onClick={() => setCollapsed((prev) => !prev)}>
|
<button onClick={() => onCollapse(!collapsed, data.id)}>
|
||||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,12 +94,6 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
|
||||||
<div className={style.group} style={binderColours ? { '--user-bg': binderColours.backgroundColor } : {}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={style.footer} style={binderColours ? { '--user-bg': binderColours.backgroundColor } : {}} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@use '../blockMixins' as *;
|
||||||
|
|
||||||
|
.blockEnd {
|
||||||
|
cursor: default;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: var(--user-bg, $gray-1050);
|
||||||
|
|
||||||
|
border-radius: 0 0 $block-border-radius $block-border-radius;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
import style from './BlockEnd.module.scss';
|
||||||
|
|
||||||
|
interface BlockEndProps {
|
||||||
|
id: string;
|
||||||
|
colour?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlockEnd(props: BlockEndProps) {
|
||||||
|
const { id, colour } = props;
|
||||||
|
const {
|
||||||
|
attributes: dragAttributes,
|
||||||
|
listeners: dragListeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({
|
||||||
|
id,
|
||||||
|
animateLayoutChanges: () => false,
|
||||||
|
disabled: true, // we do not want to drag end blocks
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragStyle = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={style.blockEnd}
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...dragAttributes}
|
||||||
|
{...dragListeners}
|
||||||
|
style={{
|
||||||
|
...dragStyle,
|
||||||
|
...(colour ? { '--user-bg': colour } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
.delay {
|
.delay {
|
||||||
@include block-styling;
|
@include block-styling;
|
||||||
|
|
||||||
|
margin-block: 0.25rem;
|
||||||
background-color: $block-bg2;
|
background-color: $block-bg2;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,19 @@ export default function DelayBlock(props: DelayBlockProps) {
|
|||||||
attributes: dragAttributes,
|
attributes: dragAttributes,
|
||||||
listeners: dragListeners,
|
listeners: dragListeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
|
isDragging,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
data: {
|
||||||
|
type: 'delay',
|
||||||
|
},
|
||||||
animateLayoutChanges: () => false,
|
animateLayoutChanges: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
|
zIndex: isDragging ? 2 : 'inherit',
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
|
|||||||
.eventBlock {
|
.eventBlock {
|
||||||
@include block-styling;
|
@include block-styling;
|
||||||
background-color: $block-bg;
|
background-color: $block-bg;
|
||||||
|
margin-block: 0.25rem;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { EndAction, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
@@ -45,6 +45,7 @@ interface EventBlockProps {
|
|||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
skip: boolean;
|
skip: boolean;
|
||||||
|
parent: EntryId | null;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
@@ -86,6 +87,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
isPast,
|
isPast,
|
||||||
isNext,
|
isNext,
|
||||||
skip = false,
|
skip = false,
|
||||||
|
parent,
|
||||||
loaded,
|
loaded,
|
||||||
hasCursor,
|
hasCursor,
|
||||||
playback,
|
playback,
|
||||||
@@ -193,6 +195,10 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
transition,
|
transition,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
|
data: {
|
||||||
|
type: 'event',
|
||||||
|
parent,
|
||||||
|
},
|
||||||
animateLayoutChanges: () => false,
|
animateLayoutChanges: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
margin: 0.25rem 0;
|
padding-block: 0.5rem;
|
||||||
font-size: calc(1rem - 3px);
|
font-size: calc(1rem - 3px);
|
||||||
margin-left: calc(2em + 0.5rem);
|
padding-left: calc(2em + 0.5rem);
|
||||||
|
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickBtn {
|
.quickBtn {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import style from './QuickAddBlock.module.scss';
|
|||||||
interface QuickAddBlockProps {
|
interface QuickAddBlockProps {
|
||||||
previousEventId: MaybeString;
|
previousEventId: MaybeString;
|
||||||
parentBlock: MaybeString;
|
parentBlock: MaybeString;
|
||||||
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(QuickAddBlock);
|
export default memo(QuickAddBlock);
|
||||||
|
|
||||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||||
const { previousEventId, parentBlock } = props;
|
const { previousEventId, parentBlock, backgroundColor } = props;
|
||||||
const { addEntry } = useEntryActions();
|
const { addEntry } = useEntryActions();
|
||||||
|
|
||||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -25,7 +26,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
|||||||
addEntry(
|
addEntry(
|
||||||
{
|
{
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
parent: parentBlock ?? null,
|
parent: parentBlock,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
after: previousEventId,
|
after: previousEventId,
|
||||||
@@ -38,8 +39,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
|||||||
|
|
||||||
const addDelay = () => {
|
const addDelay = () => {
|
||||||
addEntry(
|
addEntry(
|
||||||
// TODO(v4): add delays to blocks
|
{ type: SupportedEntry.Delay, parent: parentBlock },
|
||||||
{ type: SupportedEntry.Delay },
|
|
||||||
{
|
{
|
||||||
lastEventId: previousEventId,
|
lastEventId: previousEventId,
|
||||||
after: previousEventId,
|
after: previousEventId,
|
||||||
@@ -60,8 +60,15 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the colour is empty string ''
|
||||||
|
* ie: we are inside a block, but there is no defined colour
|
||||||
|
* we default to $gray-1050 #303030
|
||||||
|
*/
|
||||||
|
const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.quickAdd}>
|
<div className={style.quickAdd} style={blockColour ? { '--user-bg': blockColour } : {}}>
|
||||||
<Button
|
<Button
|
||||||
onClick={addEvent}
|
onClick={addEvent}
|
||||||
size='xs'
|
size='xs'
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, PlayableEvent } from 'ontime-types';
|
import {
|
||||||
|
EntryId,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeEvent,
|
||||||
|
isPlayableEvent,
|
||||||
|
MaybeString,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeEntry,
|
||||||
|
OntimeEvent,
|
||||||
|
PlayableEvent,
|
||||||
|
RundownEntries,
|
||||||
|
SupportedEntry,
|
||||||
|
} from 'ontime-types';
|
||||||
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
||||||
|
|
||||||
type RundownMetadata = {
|
type RundownMetadata = {
|
||||||
@@ -8,11 +20,12 @@ type RundownMetadata = {
|
|||||||
thisId: MaybeString;
|
thisId: MaybeString;
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isNext: boolean;
|
|
||||||
isNextDay: boolean;
|
isNextDay: boolean;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
|
groupId: MaybeString;
|
||||||
|
groupColour: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,11 +39,12 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
|
|||||||
thisId: null,
|
thisId: null,
|
||||||
eventIndex: 0,
|
eventIndex: 0,
|
||||||
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
||||||
isNext: false,
|
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||||
@@ -39,12 +53,11 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
|
|||||||
return rundownMeta;
|
return rundownMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return process;
|
return { metadata: rundownMeta, process };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receives a rundown entry and processes its place in the rundown
|
* Receives a rundown entry and processes its place in the rundown
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
function processEntry(
|
function processEntry(
|
||||||
rundownMetadata: RundownMetadata,
|
rundownMetadata: RundownMetadata,
|
||||||
@@ -52,10 +65,12 @@ function processEntry(
|
|||||||
entry: Readonly<OntimeEntry>,
|
entry: Readonly<OntimeEntry>,
|
||||||
): Readonly<RundownMetadata> {
|
): Readonly<RundownMetadata> {
|
||||||
const processedData = { ...rundownMetadata };
|
const processedData = { ...rundownMetadata };
|
||||||
|
// initialise data to be overridden below
|
||||||
processedData.isNextDay = false;
|
processedData.isNextDay = false;
|
||||||
processedData.isLoaded = false;
|
processedData.isLoaded = false;
|
||||||
processedData.previousEntryId = processedData.thisId;
|
|
||||||
processedData.thisId = entry.id;
|
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
||||||
|
processedData.thisId = entry.id; // we reassign thisId
|
||||||
processedData.previousEvent = processedData.latestEvent;
|
processedData.previousEvent = processedData.latestEvent;
|
||||||
|
|
||||||
if (entry.id === selectedEventId) {
|
if (entry.id === selectedEventId) {
|
||||||
@@ -63,29 +78,103 @@ function processEntry(
|
|||||||
processedData.isPast = false;
|
processedData.isPast = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeBlock(entry)) {
|
||||||
// event indexes are 1 based in UI
|
processedData.groupId = entry.id;
|
||||||
processedData.eventIndex += 1;
|
processedData.groupColour = entry.colour;
|
||||||
|
} else {
|
||||||
if (isPlayableEvent(entry)) {
|
// for delays and blocks, we insert the group metadata
|
||||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) {
|
||||||
processedData.totalGap += entry.gap;
|
// if the parent is not the current group, we need to update the groupId
|
||||||
|
processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent;
|
||||||
if (!processedData.isPast && !processedData.isLoaded) {
|
if ((entry as OntimeEvent | OntimeDelay).parent === null) {
|
||||||
/**
|
// if the entry has no parent, it cannot have a group colour
|
||||||
* isLinkToLoaded is a chain value that we maintain until we
|
processedData.groupColour = undefined;
|
||||||
* a) find an unlinked event
|
|
||||||
* b) find a countToEnd event
|
|
||||||
*/
|
|
||||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isNewLatest(entry, processedData.previousEvent)) {
|
if (isOntimeEvent(entry)) {
|
||||||
// this event is the forward most event in rundown, for next iteration
|
// event indexes are 1 based in UI
|
||||||
processedData.latestEvent = entry;
|
processedData.eventIndex += 1;
|
||||||
|
|
||||||
|
if (isPlayableEvent(entry)) {
|
||||||
|
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||||
|
processedData.totalGap += entry.gap;
|
||||||
|
|
||||||
|
if (!processedData.isPast && !processedData.isLoaded) {
|
||||||
|
/**
|
||||||
|
* isLinkToLoaded is a chain value that we maintain until we
|
||||||
|
* a) find an unlinked event
|
||||||
|
* b) find a countToEnd event
|
||||||
|
*/
|
||||||
|
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||||
|
// this event is the forward most event in rundown, for next iteration
|
||||||
|
processedData.latestEvent = entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedData;
|
return processedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sortable list of entries
|
||||||
|
* ------------------------------------
|
||||||
|
* Due to limitations in dnd-kit we need to flatten the list of entries
|
||||||
|
* This list should also be aware of any elements that are sortable (ie: block ends)
|
||||||
|
*/
|
||||||
|
export function makeSortableList(flatOrder: EntryId[], entries: RundownEntries): EntryId[] {
|
||||||
|
const entryIds: EntryId[] = [];
|
||||||
|
let lastSeenBlock: MaybeString = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < flatOrder.length; i++) {
|
||||||
|
const entry = entries[flatOrder[i]];
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeBlock(entry)) {
|
||||||
|
// close any previous blocks
|
||||||
|
if (lastSeenBlock !== null) {
|
||||||
|
entryIds.push(`end-${lastSeenBlock}`);
|
||||||
|
}
|
||||||
|
lastSeenBlock = entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
|
// Close the previous block if the parent changes
|
||||||
|
if (lastSeenBlock !== null && entry.parent !== lastSeenBlock) {
|
||||||
|
entryIds.push(`end-${lastSeenBlock}`);
|
||||||
|
}
|
||||||
|
lastSeenBlock = entry.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIds.push(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// double check that we close any dangling blocks
|
||||||
|
// - if the last element is a block
|
||||||
|
// - if a rundown only has a top level block
|
||||||
|
if (lastSeenBlock !== null) {
|
||||||
|
entryIds.push(`end-${lastSeenBlock}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entryIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a drop operation is valid
|
||||||
|
* Currently only used for validating dropping blocks
|
||||||
|
*/
|
||||||
|
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
|
||||||
|
if (targetType === 'event' || targetType === 'delay') {
|
||||||
|
return targetParent === null;
|
||||||
|
}
|
||||||
|
// remaining events will be block or end-block
|
||||||
|
// we can swap places with other blocks
|
||||||
|
return targetType == 'block';
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import xlsx from 'xlsx';
|
|||||||
import type { WorkBook } from 'xlsx';
|
import type { WorkBook } from 'xlsx';
|
||||||
|
|
||||||
import { parseExcel } from '../../utils/parser.js';
|
import { parseExcel } from '../../utils/parser.js';
|
||||||
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
|
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||||
import { deleteFile } from '../../utils/parserUtils.js';
|
import { deleteFile } from '../../utils/parserUtils.js';
|
||||||
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
|
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
|
||||||
|
|
||||||
|
import { parseRundown } from '../rundown/rundown.parser.js';
|
||||||
|
|
||||||
let excelData: WorkBook = xlsx.utils.book_new();
|
let excelData: WorkBook = xlsx.utils.book_new();
|
||||||
|
|
||||||
export async function saveExcelFile(filePath: string) {
|
export async function saveExcelFile(filePath: string) {
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
|
||||||
|
|
||||||
|
import { defaultRundown } from '../../../models/dataModel.js';
|
||||||
|
import { makeOntimeBlock, makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
|
import { parseRundowns, parseRundown } from '../rundown.parser.js';
|
||||||
|
|
||||||
|
describe('parseRundowns()', () => {
|
||||||
|
it('returns a default project rundown if nothing is given', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const result = parseRundowns({}, errorEmitter);
|
||||||
|
expect(result.customFields).toEqual({});
|
||||||
|
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
|
||||||
|
// one for not having custom fields
|
||||||
|
// one for not having a rundown
|
||||||
|
expect(errorEmitter).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures the rundown IDs are consistent', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const r1 = { ...defaultRundown, id: '1' };
|
||||||
|
const r2 = { ...defaultRundown, id: '2' };
|
||||||
|
const result = parseRundowns(
|
||||||
|
{
|
||||||
|
rundowns: {
|
||||||
|
'1': r1,
|
||||||
|
'3': r2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorEmitter,
|
||||||
|
);
|
||||||
|
expect(result.rundowns).toMatchObject({
|
||||||
|
'1': r1,
|
||||||
|
'2': r2,
|
||||||
|
});
|
||||||
|
// one for not having a rundown
|
||||||
|
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRundown()', () => {
|
||||||
|
it('parses data, skipping invalid results', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const rundown = {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2', '3', '4'],
|
||||||
|
flatOrder: ['1', '2', '3', '4'],
|
||||||
|
entries: {
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK
|
||||||
|
'2': { id: '1', type: SupportedEntry.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID
|
||||||
|
'3': {} as OntimeEvent, // no data
|
||||||
|
'4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {}, errorEmitter);
|
||||||
|
expect(parsedRundown.id).not.toBe('');
|
||||||
|
expect(parsedRundown.id).toBeTypeOf('string');
|
||||||
|
expect(parsedRundown.order.length).toEqual(1);
|
||||||
|
expect(parsedRundown.order).toEqual(['1']);
|
||||||
|
expect(parsedRundown.entries).toMatchObject({
|
||||||
|
'1': {
|
||||||
|
id: '1',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
title: 'test',
|
||||||
|
skip: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(errorEmitter).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringifies necessary values', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2'],
|
||||||
|
flatOrder: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
// @ts-expect-error -- testing external data which could be incorrect
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event, cue: 101 } as OntimeEvent,
|
||||||
|
// @ts-expect-error -- testing external data which could be incorrect
|
||||||
|
'2': { id: '2', type: SupportedEntry.Event, cue: 101.1 } as OntimeEvent,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
expect(parseRundown(rundown, {})).toMatchObject({
|
||||||
|
entries: {
|
||||||
|
'1': {
|
||||||
|
cue: '101',
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
cue: '101.1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects duplicate Ids', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '1'],
|
||||||
|
flatOrder: ['1', '1'],
|
||||||
|
entries: {
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.order.length).toEqual(1);
|
||||||
|
expect(Object.keys(parsedRundown.entries).length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes partial datasets', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2'],
|
||||||
|
flatOrder: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.order.length).toEqual(2);
|
||||||
|
expect(parsedRundown.entries).toMatchObject({
|
||||||
|
'1': {
|
||||||
|
title: '',
|
||||||
|
cue: '1',
|
||||||
|
custom: {},
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
title: '',
|
||||||
|
cue: '2',
|
||||||
|
custom: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty events', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2', '3', '4'],
|
||||||
|
flatOrder: ['1', '2', '3', '4'],
|
||||||
|
entries: {
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
'not-mentioned': {} as OntimeEvent,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.order.length).toEqual(2);
|
||||||
|
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty events', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2', '3', '4'],
|
||||||
|
flatOrder: ['1', '2', '3', '4'],
|
||||||
|
entries: {
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
|
'not-mentioned': {} as OntimeEvent,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.order.length).toEqual(2);
|
||||||
|
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses events nested in blocks', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['block'],
|
||||||
|
flatOrder: ['block'],
|
||||||
|
entries: {
|
||||||
|
block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }),
|
||||||
|
'1': makeOntimeEvent({ id: '1' }),
|
||||||
|
'2': makeOntimeEvent({ id: '2' }),
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.order.length).toEqual(1);
|
||||||
|
expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] });
|
||||||
|
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
|
import { createEvent } from '../rundown.utils.js';
|
||||||
|
|
||||||
|
describe('test event validator', () => {
|
||||||
|
it('validates a good object', () => {
|
||||||
|
const event = {
|
||||||
|
title: 'test',
|
||||||
|
};
|
||||||
|
const validated = createEvent(event, 1);
|
||||||
|
|
||||||
|
expect(validated).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.any(String),
|
||||||
|
note: expect.any(String),
|
||||||
|
timeStart: expect.any(Number),
|
||||||
|
timeEnd: expect.any(Number),
|
||||||
|
countToEnd: expect.any(Boolean),
|
||||||
|
isPublic: expect.any(Boolean),
|
||||||
|
skip: expect.any(Boolean),
|
||||||
|
revision: expect.any(Number),
|
||||||
|
type: expect.any(String),
|
||||||
|
id: expect.any(String),
|
||||||
|
cue: '2',
|
||||||
|
colour: expect.any(String),
|
||||||
|
custom: expect.any(Object),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails an empty object', () => {
|
||||||
|
const event = {};
|
||||||
|
const validated = createEvent(event, 1);
|
||||||
|
expect(validated).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes objects strings', () => {
|
||||||
|
const event = {
|
||||||
|
title: 2,
|
||||||
|
note: '1899-12-30T08:00:10.000Z',
|
||||||
|
};
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
const validated = createEvent(event, 1);
|
||||||
|
if (validated === null) {
|
||||||
|
throw new Error('unexpected value');
|
||||||
|
}
|
||||||
|
expect(typeof validated.title).toEqual('string');
|
||||||
|
expect(typeof validated.note).toEqual('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces numbers on times', () => {
|
||||||
|
const event = {
|
||||||
|
timeStart: false,
|
||||||
|
timeEnd: '2',
|
||||||
|
};
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
const validated = createEvent(event);
|
||||||
|
if (validated === null) {
|
||||||
|
throw new Error('unexpected value');
|
||||||
|
}
|
||||||
|
assertType<number>(validated.timeStart);
|
||||||
|
assertType<number>(validated.timeEnd);
|
||||||
|
assertType<number>(validated.duration);
|
||||||
|
expect(validated.timeStart).toEqual(0);
|
||||||
|
expect(validated.timeEnd).toEqual(2);
|
||||||
|
expect(validated.duration).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles bad objects', () => {
|
||||||
|
const event = {
|
||||||
|
title: {},
|
||||||
|
};
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
const validated = createEvent(event);
|
||||||
|
if (validated === null) {
|
||||||
|
throw new Error('unexpected value');
|
||||||
|
}
|
||||||
|
expect(typeof validated.title).toEqual('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
DatabaseModel,
|
||||||
|
CustomFields,
|
||||||
|
ProjectRundowns,
|
||||||
|
Rundown,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeBlock,
|
||||||
|
isOntimeEvent,
|
||||||
|
isOntimeDelay,
|
||||||
|
isOntimeBlock,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { isObjectEmpty, generateId } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { defaultRundown } from '../../models/dataModel.js';
|
||||||
|
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
||||||
|
import { ErrorEmitter } from '../../utils/parser.js';
|
||||||
|
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||||
|
|
||||||
|
import { createEvent } from './rundown.utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a rundowns object along with the project custom fields
|
||||||
|
* Returns a default rundown if none exists
|
||||||
|
*/
|
||||||
|
export function parseRundowns(
|
||||||
|
data: Partial<DatabaseModel>,
|
||||||
|
emitError?: ErrorEmitter,
|
||||||
|
): { customFields: CustomFields; rundowns: ProjectRundowns } {
|
||||||
|
// check custom fields first
|
||||||
|
const parsedCustomFields = parseCustomFields(data, emitError);
|
||||||
|
|
||||||
|
// ensure there is always a rundown to import
|
||||||
|
// this is important since the rest of the app assumes this exist
|
||||||
|
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
|
||||||
|
emitError?.('No data found to import');
|
||||||
|
return {
|
||||||
|
customFields: parsedCustomFields,
|
||||||
|
rundowns: {
|
||||||
|
default: {
|
||||||
|
...defaultRundown,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedRundowns: ProjectRundowns = {};
|
||||||
|
const iterableRundownsIds = Object.keys(data.rundowns);
|
||||||
|
|
||||||
|
// parse all the rundowns individually
|
||||||
|
for (const id of iterableRundownsIds) {
|
||||||
|
console.log('Found rundown, importing...');
|
||||||
|
const rundown = data.rundowns[id];
|
||||||
|
const parsedRundown = parseRundown(rundown, parsedCustomFields, emitError);
|
||||||
|
parsedRundowns[parsedRundown.id] = parsedRundown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates a single project rundown along with given project custom fields
|
||||||
|
*/
|
||||||
|
export function parseRundown(
|
||||||
|
rundown: Rundown,
|
||||||
|
parsedCustomFields: Readonly<CustomFields>,
|
||||||
|
emitError?: ErrorEmitter,
|
||||||
|
): Rundown {
|
||||||
|
const parsedRundown: Rundown = {
|
||||||
|
id: rundown.id || generateId(),
|
||||||
|
title: rundown.title ?? '',
|
||||||
|
entries: {},
|
||||||
|
order: [],
|
||||||
|
flatOrder: [],
|
||||||
|
revision: rundown.revision ?? 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let eventIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < rundown.order.length; i++) {
|
||||||
|
const entryId = rundown.order[i];
|
||||||
|
const event = rundown.entries[entryId];
|
||||||
|
|
||||||
|
if (event === undefined) {
|
||||||
|
emitError?.('Could not find referenced event, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedRundown.order.includes(event.id)) {
|
||||||
|
emitError?.('ID collision on event import, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = entryId;
|
||||||
|
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
|
||||||
|
const nestedEntryIds: string[] = [];
|
||||||
|
|
||||||
|
if (isOntimeEvent(event)) {
|
||||||
|
newEvent = createEvent(event, eventIndex);
|
||||||
|
// skip if event is invalid
|
||||||
|
if (newEvent == null) {
|
||||||
|
emitError?.('Skipping event without payload');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// for every field in custom, check that a key exists in customfields
|
||||||
|
for (const field in newEvent.custom) {
|
||||||
|
if (!Object.hasOwn(parsedCustomFields, field)) {
|
||||||
|
emitError?.(`Custom field ${field} not found`);
|
||||||
|
delete newEvent.custom[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventIndex += 1;
|
||||||
|
} else if (isOntimeDelay(event)) {
|
||||||
|
newEvent = { ...delayDef, duration: event.duration, id };
|
||||||
|
} else if (isOntimeBlock(event)) {
|
||||||
|
for (let i = 0; i < event.events.length; i++) {
|
||||||
|
const nestedEventId = event.events[i];
|
||||||
|
const nestedEvent = rundown.entries[nestedEventId];
|
||||||
|
|
||||||
|
if (isOntimeEvent(nestedEvent)) {
|
||||||
|
const newNestedEvent = createEvent(nestedEvent, eventIndex);
|
||||||
|
// skip if event is invalid
|
||||||
|
if (newNestedEvent == null) {
|
||||||
|
emitError?.('Skipping event without payload');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// for every field in custom, check that a key exists in customfields
|
||||||
|
for (const field in newNestedEvent.custom) {
|
||||||
|
if (!Object.hasOwn(parsedCustomFields, field)) {
|
||||||
|
emitError?.(`Custom field ${field} not found`);
|
||||||
|
delete newNestedEvent.custom[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventIndex += 1;
|
||||||
|
|
||||||
|
if (newNestedEvent) {
|
||||||
|
nestedEntryIds.push(nestedEventId);
|
||||||
|
parsedRundown.entries[nestedEventId] = newNestedEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newEvent = {
|
||||||
|
...blockDef,
|
||||||
|
title: event.title,
|
||||||
|
note: event.note,
|
||||||
|
events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
|
||||||
|
skip: event.skip,
|
||||||
|
colour: event.colour,
|
||||||
|
custom: { ...event.custom },
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
emitError?.('Unknown event type, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEvent) {
|
||||||
|
parsedRundown.entries[id] = newEvent;
|
||||||
|
parsedRundown.order.push(id);
|
||||||
|
parsedRundown.flatOrder.push(id);
|
||||||
|
parsedRundown.flatOrder.push(...nestedEntryIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
|
||||||
|
return parsedRundown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||||
|
import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { event as eventDef } from '../../models/eventsDefinition.js';
|
||||||
|
import { makeString } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
|
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||||
|
if (Object.keys(patchEvent).length === 0) {
|
||||||
|
return originalEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
|
||||||
|
patchEvent?.timeStart ?? originalEvent.timeStart,
|
||||||
|
patchEvent?.timeEnd ?? originalEvent.timeEnd,
|
||||||
|
patchEvent?.duration ?? originalEvent.duration,
|
||||||
|
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: originalEvent.id,
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
title: makeString(patchEvent.title, originalEvent.title),
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
duration,
|
||||||
|
timeStrategy,
|
||||||
|
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
|
||||||
|
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
||||||
|
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
||||||
|
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
||||||
|
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
|
||||||
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||||
|
note: makeString(patchEvent.note, originalEvent.note),
|
||||||
|
colour: makeString(patchEvent.colour, originalEvent.colour),
|
||||||
|
delay: originalEvent.delay, // is regenerated if timer related data is changed
|
||||||
|
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
|
||||||
|
gap: originalEvent.gap, // is regenerated if timer related data is changed
|
||||||
|
// short circuit empty string
|
||||||
|
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||||
|
parent: originalEvent.parent,
|
||||||
|
revision: originalEvent.revision,
|
||||||
|
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
||||||
|
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
||||||
|
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
||||||
|
triggers: patchEvent.triggers ?? originalEvent.triggers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Enforces formatting for events
|
||||||
|
* @param {object} eventArgs - attributes of event
|
||||||
|
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
||||||
|
* @returns {object|null} - formatted object or null in case is invalid
|
||||||
|
*/
|
||||||
|
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
|
||||||
|
if (Object.keys(eventArgs).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
id: eventArgs?.id ?? generateId(),
|
||||||
|
cue,
|
||||||
|
...eventDef,
|
||||||
|
};
|
||||||
|
const event = createPatch(baseEvent, eventArgs);
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function infers strategy for a patch with only partial timer data
|
||||||
|
* @param end
|
||||||
|
* @param duration
|
||||||
|
* @param fallback
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
|
||||||
|
if (end && !duration) {
|
||||||
|
return TimeStrategy.LockEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!end && duration) {
|
||||||
|
return TimeStrategy.LockDuration;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
|||||||
export const delay: Omit<OntimeDelay, 'id'> = {
|
export const delay: Omit<OntimeDelay, 'id'> = {
|
||||||
type: SupportedEntry.Delay,
|
type: SupportedEntry.Delay,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
parent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const block: Omit<OntimeBlock, 'id'> = {
|
export const block: Omit<OntimeBlock, 'id'> = {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
import { deleteFile } from '../../utils/parserUtils.js';
|
import { deleteFile } from '../../utils/parserUtils.js';
|
||||||
import { parseDatabaseModel } from '../../utils/parser.js';
|
import { parseDatabaseModel } from '../../utils/parser.js';
|
||||||
import { parseRundowns } from '../../utils/parserFunctions.js';
|
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||||
import { demoDb } from '../../models/demoProject.js';
|
import { demoDb } from '../../models/demoProject.js';
|
||||||
import { config } from '../../setup/config.js';
|
import { config } from '../../setup/config.js';
|
||||||
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { getCueCandidate } from 'ontime-utils';
|
|||||||
|
|
||||||
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
||||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||||
import { createEvent } from '../../utils/parser.js';
|
import { createEvent } from '../../api-data/rundown/rundown.utils.js';
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils';
|
import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { createPatch } from '../../utils/parser.js';
|
import { createPatch } from '../../api-data/rundown/rundown.utils.js';
|
||||||
|
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import { apply } from './delayUtils.js';
|
import { apply } from './delayUtils.js';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import got from 'got';
|
|||||||
|
|
||||||
import { parseExcel } from '../../utils/parser.js';
|
import { parseExcel } from '../../utils/parser.js';
|
||||||
import { logger } from '../../classes/Logger.js';
|
import { logger } from '../../classes/Logger.js';
|
||||||
import { parseRundowns } from '../../utils/parserFunctions.js';
|
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||||
|
|
||||||
import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js';
|
import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js';
|
||||||
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
|
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable no-console -- we are mocking the console */
|
/* eslint-disable no-console -- we are mocking the console */
|
||||||
import { assertType, vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
import { CustomFields, DatabaseModel, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
import { CustomFields, DatabaseModel, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
||||||
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
@@ -7,7 +7,7 @@ import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
|||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
import { demoDb } from '../../models/demoProject.js';
|
import { demoDb } from '../../models/demoProject.js';
|
||||||
|
|
||||||
import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
|
import { getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
|
||||||
import { makeString } from '../parserUtils.js';
|
import { makeString } from '../parserUtils.js';
|
||||||
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
||||||
|
|
||||||
@@ -64,83 +64,6 @@ describe('test parseDatabaseModel() edge cases', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('test event validator', () => {
|
|
||||||
it('validates a good object', () => {
|
|
||||||
const event = {
|
|
||||||
title: 'test',
|
|
||||||
};
|
|
||||||
const validated = createEvent(event, 1);
|
|
||||||
|
|
||||||
expect(validated).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
title: expect.any(String),
|
|
||||||
note: expect.any(String),
|
|
||||||
timeStart: expect.any(Number),
|
|
||||||
timeEnd: expect.any(Number),
|
|
||||||
countToEnd: expect.any(Boolean),
|
|
||||||
isPublic: expect.any(Boolean),
|
|
||||||
skip: expect.any(Boolean),
|
|
||||||
revision: expect.any(Number),
|
|
||||||
type: expect.any(String),
|
|
||||||
id: expect.any(String),
|
|
||||||
cue: '2',
|
|
||||||
colour: expect.any(String),
|
|
||||||
custom: expect.any(Object),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails an empty object', () => {
|
|
||||||
const event = {};
|
|
||||||
const validated = createEvent(event, 1);
|
|
||||||
expect(validated).toEqual(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes objects strings', () => {
|
|
||||||
const event = {
|
|
||||||
title: 2,
|
|
||||||
note: '1899-12-30T08:00:10.000Z',
|
|
||||||
};
|
|
||||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
||||||
const validated = createEvent(event, 1);
|
|
||||||
if (validated === null) {
|
|
||||||
throw new Error('unexpected value');
|
|
||||||
}
|
|
||||||
expect(typeof validated.title).toEqual('string');
|
|
||||||
expect(typeof validated.note).toEqual('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces numbers on times', () => {
|
|
||||||
const event = {
|
|
||||||
timeStart: false,
|
|
||||||
timeEnd: '2',
|
|
||||||
};
|
|
||||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
||||||
const validated = createEvent(event);
|
|
||||||
if (validated === null) {
|
|
||||||
throw new Error('unexpected value');
|
|
||||||
}
|
|
||||||
assertType<number>(validated.timeStart);
|
|
||||||
assertType<number>(validated.timeEnd);
|
|
||||||
assertType<number>(validated.duration);
|
|
||||||
expect(validated.timeStart).toEqual(0);
|
|
||||||
expect(validated.timeEnd).toEqual(2);
|
|
||||||
expect(validated.duration).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles bad objects', () => {
|
|
||||||
const event = {
|
|
||||||
title: {},
|
|
||||||
};
|
|
||||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
||||||
const validated = createEvent(event);
|
|
||||||
if (validated === null) {
|
|
||||||
throw new Error('unexpected value');
|
|
||||||
}
|
|
||||||
expect(typeof validated.title).toEqual('string');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test aliases import', () => {
|
describe('test aliases import', () => {
|
||||||
it('imports a well defined urlPreset', () => {
|
it('imports a well defined urlPreset', () => {
|
||||||
const testData = {
|
const testData = {
|
||||||
|
|||||||
@@ -1,217 +1,13 @@
|
|||||||
import { CustomFields, OntimeBlock, OntimeEvent, Rundown, Settings, SupportedEntry, URLPreset } from 'ontime-types';
|
import { CustomFields, Settings, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
import { defaultRundown } from '../../models/dataModel.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseCustomFields,
|
parseCustomFields,
|
||||||
parseProject,
|
parseProject,
|
||||||
parseRundown,
|
|
||||||
parseRundowns,
|
|
||||||
parseSettings,
|
parseSettings,
|
||||||
parseUrlPresets,
|
parseUrlPresets,
|
||||||
parseViewSettings,
|
parseViewSettings,
|
||||||
sanitiseCustomFields,
|
sanitiseCustomFields,
|
||||||
} from '../parserFunctions.js';
|
} from '../parserFunctions.js';
|
||||||
import { makeOntimeBlock, makeOntimeEvent } from '../../services/rundown-service/__mocks__/rundown.mocks.js';
|
|
||||||
|
|
||||||
describe('parseRundowns()', () => {
|
|
||||||
it('returns a default project rundown if nothing is given', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const result = parseRundowns({}, errorEmitter);
|
|
||||||
expect(result.customFields).toEqual({});
|
|
||||||
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
|
|
||||||
// one for not having custom fields
|
|
||||||
// one for not having a rundown
|
|
||||||
expect(errorEmitter).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ensures the rundown IDs are consistent', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const r1 = { ...defaultRundown, id: '1' };
|
|
||||||
const r2 = { ...defaultRundown, id: '2' };
|
|
||||||
const result = parseRundowns(
|
|
||||||
{
|
|
||||||
rundowns: {
|
|
||||||
'1': r1,
|
|
||||||
'3': r2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
errorEmitter,
|
|
||||||
);
|
|
||||||
expect(result.rundowns).toMatchObject({
|
|
||||||
'1': r1,
|
|
||||||
'2': r2,
|
|
||||||
});
|
|
||||||
// one for not having a rundown
|
|
||||||
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseRundown()', () => {
|
|
||||||
it('parses data, skipping invalid results', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const rundown = {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
flatOrder: ['1', '2', '3', '4'],
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK
|
|
||||||
'2': { id: '1', type: SupportedEntry.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID
|
|
||||||
'3': {} as OntimeEvent, // no data
|
|
||||||
'4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
const parsedRundown = parseRundown(rundown, {}, errorEmitter);
|
|
||||||
expect(parsedRundown.id).not.toBe('');
|
|
||||||
expect(parsedRundown.id).toBeTypeOf('string');
|
|
||||||
expect(parsedRundown.order.length).toEqual(1);
|
|
||||||
expect(parsedRundown.order).toEqual(['1']);
|
|
||||||
expect(parsedRundown.entries).toMatchObject({
|
|
||||||
'1': {
|
|
||||||
id: '1',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
title: 'test',
|
|
||||||
skip: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(errorEmitter).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stringifies necessary values', () => {
|
|
||||||
const rundown = {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
order: ['1', '2'],
|
|
||||||
flatOrder: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
// @ts-expect-error -- testing external data which could be incorrect
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event, cue: 101 } as OntimeEvent,
|
|
||||||
// @ts-expect-error -- testing external data which could be incorrect
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event, cue: 101.1 } as OntimeEvent,
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
expect(parseRundown(rundown, {})).toMatchObject({
|
|
||||||
entries: {
|
|
||||||
'1': {
|
|
||||||
cue: '101',
|
|
||||||
},
|
|
||||||
'2': {
|
|
||||||
cue: '101.1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects duplicate Ids', () => {
|
|
||||||
const rundown = {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
order: ['1', '1'],
|
|
||||||
flatOrder: ['1', '1'],
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
const parsedRundown = parseRundown(rundown, {});
|
|
||||||
expect(parsedRundown.order.length).toEqual(1);
|
|
||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('completes partial datasets', () => {
|
|
||||||
const rundown = {
|
|
||||||
id: 'test',
|
|
||||||
title: '',
|
|
||||||
order: ['1', '2'],
|
|
||||||
flatOrder: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
const parsedRundown = parseRundown(rundown, {});
|
|
||||||
expect(parsedRundown.order.length).toEqual(2);
|
|
||||||
expect(parsedRundown.entries).toMatchObject({
|
|
||||||
'1': {
|
|
||||||
title: '',
|
|
||||||
cue: '1',
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
'2': {
|
|
||||||
title: '',
|
|
||||||
cue: '2',
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty events', () => {
|
|
||||||
const rundown = {
|
|
||||||
id: 'test',
|
|
||||||
title: '',
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
flatOrder: ['1', '2', '3', '4'],
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'not-mentioned': {} as OntimeEvent,
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
const parsedRundown = parseRundown(rundown, {});
|
|
||||||
expect(parsedRundown.order.length).toEqual(2);
|
|
||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty events', () => {
|
|
||||||
const rundown = {
|
|
||||||
id: 'test',
|
|
||||||
title: '',
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
flatOrder: ['1', '2', '3', '4'],
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'not-mentioned': {} as OntimeEvent,
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
const parsedRundown = parseRundown(rundown, {});
|
|
||||||
expect(parsedRundown.order.length).toEqual(2);
|
|
||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses events nested in blocks', () => {
|
|
||||||
const rundown = {
|
|
||||||
id: 'test',
|
|
||||||
title: '',
|
|
||||||
order: ['block'],
|
|
||||||
flatOrder: ['block'],
|
|
||||||
entries: {
|
|
||||||
block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }),
|
|
||||||
'1': makeOntimeEvent({ id: '1' }),
|
|
||||||
'2': makeOntimeEvent({ id: '2' }),
|
|
||||||
},
|
|
||||||
revision: 1,
|
|
||||||
} as Rundown;
|
|
||||||
|
|
||||||
const parsedRundown = parseRundown(rundown, {});
|
|
||||||
expect(parsedRundown.order.length).toEqual(1);
|
|
||||||
expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] });
|
|
||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseProject()', () => {
|
describe('parseProject()', () => {
|
||||||
it('returns an a base model if nothing is given', () => {
|
it('returns an a base model if nothing is given', () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
isKnownTimerType,
|
isKnownTimerType,
|
||||||
validateEndAction,
|
validateEndAction,
|
||||||
validateTimerType,
|
validateTimerType,
|
||||||
validateTimes,
|
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
@@ -20,17 +19,16 @@ import {
|
|||||||
Rundown,
|
Rundown,
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
TimerType,
|
TimerType,
|
||||||
TimeStrategy,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { Merge } from 'ts-essentials';
|
import { Merge } from 'ts-essentials';
|
||||||
|
|
||||||
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
|
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
|
||||||
|
import { parseRundowns } from '../api-data/rundown/rundown.parser.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
|
||||||
|
|
||||||
import { makeString } from './parserUtils.js';
|
import { makeString } from './parserUtils.js';
|
||||||
import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
|
import { parseProject, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
|
||||||
import { parseExcelDate } from './time.js';
|
import { parseExcelDate } from './time.js';
|
||||||
import { is } from './is.js';
|
import { is } from './is.js';
|
||||||
|
|
||||||
@@ -374,85 +372,3 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: Da
|
|||||||
|
|
||||||
return { data, errors };
|
return { data, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Function infers strategy for a patch with only partial timer data
|
|
||||||
* @param end
|
|
||||||
* @param duration
|
|
||||||
* @param fallback
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
|
|
||||||
if (end && !duration) {
|
|
||||||
return TimeStrategy.LockEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!end && duration) {
|
|
||||||
return TimeStrategy.LockDuration;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
|
||||||
if (Object.keys(patchEvent).length === 0) {
|
|
||||||
return originalEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
|
|
||||||
patchEvent?.timeStart ?? originalEvent.timeStart,
|
|
||||||
patchEvent?.timeEnd ?? originalEvent.timeEnd,
|
|
||||||
patchEvent?.duration ?? originalEvent.duration,
|
|
||||||
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: originalEvent.id,
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
title: makeString(patchEvent.title, originalEvent.title),
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
duration,
|
|
||||||
timeStrategy,
|
|
||||||
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
|
|
||||||
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
|
||||||
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
|
||||||
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
|
||||||
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
|
|
||||||
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
|
||||||
note: makeString(patchEvent.note, originalEvent.note),
|
|
||||||
colour: makeString(patchEvent.colour, originalEvent.colour),
|
|
||||||
delay: originalEvent.delay, // is regenerated if timer related data is changed
|
|
||||||
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
|
|
||||||
gap: originalEvent.gap, // is regenerated if timer related data is changed
|
|
||||||
// short circuit empty string
|
|
||||||
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
|
||||||
parent: originalEvent.parent,
|
|
||||||
revision: originalEvent.revision,
|
|
||||||
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
|
||||||
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
|
||||||
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
|
||||||
triggers: patchEvent.triggers ?? originalEvent.triggers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Enforces formatting for events
|
|
||||||
* @param {object} eventArgs - attributes of event
|
|
||||||
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
|
||||||
* @returns {object|null} - formatted object or null in case is invalid
|
|
||||||
*/
|
|
||||||
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
|
|
||||||
if (Object.keys(eventArgs).length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
|
||||||
|
|
||||||
const baseEvent = {
|
|
||||||
id: eventArgs?.id ?? generateId(),
|
|
||||||
cue,
|
|
||||||
...eventDef,
|
|
||||||
};
|
|
||||||
const event = createPatch(baseEvent, eventArgs);
|
|
||||||
return event;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,178 +1,9 @@
|
|||||||
import {
|
import { CustomField, CustomFields, DatabaseModel, ProjectData, Settings, URLPreset, ViewSettings } from 'ontime-types';
|
||||||
CustomField,
|
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
CustomFields,
|
|
||||||
DatabaseModel,
|
|
||||||
OntimeBlock,
|
|
||||||
OntimeDelay,
|
|
||||||
OntimeEvent,
|
|
||||||
ProjectData,
|
|
||||||
ProjectRundowns,
|
|
||||||
Rundown,
|
|
||||||
Settings,
|
|
||||||
URLPreset,
|
|
||||||
ViewSettings,
|
|
||||||
isOntimeBlock,
|
|
||||||
isOntimeDelay,
|
|
||||||
isOntimeEvent,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace, isObjectEmpty } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { dbModel, defaultRundown } from '../models/dataModel.js';
|
import { dbModel } from '../models/dataModel.js';
|
||||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
|
||||||
|
|
||||||
import { createEvent, type ErrorEmitter } from './parser.js';
|
import { type ErrorEmitter } from './parser.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a rundowns object along with the project custom fields
|
|
||||||
* Returns a default rundown if none exists
|
|
||||||
*/
|
|
||||||
export function parseRundowns(
|
|
||||||
data: Partial<DatabaseModel>,
|
|
||||||
emitError?: ErrorEmitter,
|
|
||||||
): { customFields: CustomFields; rundowns: ProjectRundowns } {
|
|
||||||
// check custom fields first
|
|
||||||
const parsedCustomFields = parseCustomFields(data, emitError);
|
|
||||||
|
|
||||||
// ensure there is always a rundown to import
|
|
||||||
// this is important since the rest of the app assumes this exist
|
|
||||||
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
|
|
||||||
emitError?.('No data found to import');
|
|
||||||
return {
|
|
||||||
customFields: parsedCustomFields,
|
|
||||||
rundowns: {
|
|
||||||
default: {
|
|
||||||
...defaultRundown,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedRundowns: ProjectRundowns = {};
|
|
||||||
const iterableRundownsIds = Object.keys(data.rundowns);
|
|
||||||
|
|
||||||
// parse all the rundowns individually
|
|
||||||
for (const id of iterableRundownsIds) {
|
|
||||||
console.log('Found rundown, importing...');
|
|
||||||
const rundown = data.rundowns[id];
|
|
||||||
const parsedRundown = parseRundown(rundown, parsedCustomFields, emitError);
|
|
||||||
parsedRundowns[parsedRundown.id] = parsedRundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses and validates a single project rundown along with given project custom fields
|
|
||||||
*/
|
|
||||||
export function parseRundown(
|
|
||||||
rundown: Rundown,
|
|
||||||
parsedCustomFields: Readonly<CustomFields>,
|
|
||||||
emitError?: ErrorEmitter,
|
|
||||||
): Rundown {
|
|
||||||
const parsedRundown: Rundown = {
|
|
||||||
id: rundown.id || generateId(),
|
|
||||||
title: rundown.title ?? '',
|
|
||||||
entries: {},
|
|
||||||
order: [],
|
|
||||||
flatOrder: [],
|
|
||||||
revision: rundown.revision ?? 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let eventIndex = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < rundown.order.length; i++) {
|
|
||||||
const entryId = rundown.order[i];
|
|
||||||
const event = rundown.entries[entryId];
|
|
||||||
|
|
||||||
if (event === undefined) {
|
|
||||||
emitError?.('Could not find referenced event, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedRundown.order.includes(event.id)) {
|
|
||||||
emitError?.('ID collision on event import, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = entryId;
|
|
||||||
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
|
|
||||||
const nestedEntryIds: string[] = [];
|
|
||||||
|
|
||||||
if (isOntimeEvent(event)) {
|
|
||||||
newEvent = createEvent(event, eventIndex);
|
|
||||||
// skip if event is invalid
|
|
||||||
if (newEvent == null) {
|
|
||||||
emitError?.('Skipping event without payload');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// for every field in custom, check that a key exists in customfields
|
|
||||||
for (const field in newEvent.custom) {
|
|
||||||
if (!Object.hasOwn(parsedCustomFields, field)) {
|
|
||||||
emitError?.(`Custom field ${field} not found`);
|
|
||||||
delete newEvent.custom[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventIndex += 1;
|
|
||||||
} else if (isOntimeDelay(event)) {
|
|
||||||
newEvent = { ...delayDef, duration: event.duration, id };
|
|
||||||
} else if (isOntimeBlock(event)) {
|
|
||||||
for (let i = 0; i < event.events.length; i++) {
|
|
||||||
const nestedEventId = event.events[i];
|
|
||||||
const nestedEvent = rundown.entries[nestedEventId];
|
|
||||||
|
|
||||||
if (isOntimeEvent(nestedEvent)) {
|
|
||||||
const newNestedEvent = createEvent(nestedEvent, eventIndex);
|
|
||||||
// skip if event is invalid
|
|
||||||
if (newNestedEvent == null) {
|
|
||||||
emitError?.('Skipping event without payload');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// for every field in custom, check that a key exists in customfields
|
|
||||||
for (const field in newNestedEvent.custom) {
|
|
||||||
if (!Object.hasOwn(parsedCustomFields, field)) {
|
|
||||||
emitError?.(`Custom field ${field} not found`);
|
|
||||||
delete newNestedEvent.custom[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventIndex += 1;
|
|
||||||
|
|
||||||
if (newNestedEvent) {
|
|
||||||
nestedEntryIds.push(nestedEventId);
|
|
||||||
parsedRundown.entries[nestedEventId] = newNestedEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newEvent = {
|
|
||||||
...blockDef,
|
|
||||||
title: event.title,
|
|
||||||
note: event.note,
|
|
||||||
events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
|
|
||||||
skip: event.skip,
|
|
||||||
colour: event.colour,
|
|
||||||
custom: { ...event.custom },
|
|
||||||
id,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
emitError?.('Unknown event type, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent) {
|
|
||||||
parsedRundown.entries[id] = newEvent;
|
|
||||||
parsedRundown.order.push(id);
|
|
||||||
parsedRundown.flatOrder.push(id);
|
|
||||||
parsedRundown.flatOrder.push(...nestedEntryIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
|
|
||||||
return parsedRundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse event portion of an entry
|
* Parse event portion of an entry
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type EventPostPayload = Partial<OntimeEntry> & {
|
|||||||
before?: string;
|
before?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransientEventPayload = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & {
|
export type TransientEventPayload = Partial<OntimeEntry> & {
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type OntimeBaseEvent = {
|
|||||||
export type OntimeDelay = OntimeBaseEvent & {
|
export type OntimeDelay = OntimeBaseEvent & {
|
||||||
type: SupportedEntry.Delay;
|
type: SupportedEntry.Delay;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
parent: EntryId | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OntimeBlock = OntimeBaseEvent & {
|
export type OntimeBlock = OntimeBaseEvent & {
|
||||||
|
|||||||
Generated
+16
-16
@@ -90,11 +90,11 @@ importers:
|
|||||||
specifier: ^2.7.0
|
specifier: ^2.7.0
|
||||||
version: 2.7.0(@emotion/react@11.10.6(@types/react@18.0.26)(react@18.3.1))(@emotion/styled@11.10.6(@emotion/react@11.10.6(@types/react@18.0.26)(react@18.3.1))(@types/react@18.0.26)(react@18.3.1))(@types/react@18.0.26)(framer-motion@10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.7.0(@emotion/react@11.10.6(@types/react@18.0.26)(react@18.3.1))(@emotion/styled@11.10.6(@emotion/react@11.10.6(@types/react@18.0.26)(react@18.3.1))(@types/react@18.0.26)(react@18.3.1))(@types/react@18.0.26)(framer-motion@10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@dnd-kit/core':
|
'@dnd-kit/core':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.3.1
|
||||||
version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@dnd-kit/sortable':
|
'@dnd-kit/sortable':
|
||||||
specifier: ^8.0.0
|
specifier: ^10.0.0
|
||||||
version: 8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: ^3.2.2
|
specifier: ^3.2.2
|
||||||
version: 3.2.2(react@18.3.1)
|
version: 3.2.2(react@18.3.1)
|
||||||
@@ -1050,21 +1050,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
||||||
engines: {node: '>= 8.9.0'}
|
engines: {node: '>= 8.9.0'}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.0':
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@dnd-kit/core@6.1.0':
|
'@dnd-kit/core@6.3.1':
|
||||||
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
react-dom: '>=16.8.0'
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
'@dnd-kit/sortable@8.0.0':
|
'@dnd-kit/sortable@10.0.0':
|
||||||
resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@dnd-kit/core': ^6.1.0
|
'@dnd-kit/core': ^6.3.0
|
||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@dnd-kit/utilities@3.2.2':
|
'@dnd-kit/utilities@3.2.2':
|
||||||
@@ -6000,22 +6000,22 @@ snapshots:
|
|||||||
ajv: 6.12.6
|
ajv: 6.12.6
|
||||||
ajv-keywords: 3.5.2(ajv@6.12.6)
|
ajv-keywords: 3.5.2(ajv@6.12.6)
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.0(react@18.3.1)':
|
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
tslib: 2.6.2
|
tslib: 2.6.2
|
||||||
|
|
||||||
'@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dnd-kit/accessibility': 3.1.0(react@18.3.1)
|
'@dnd-kit/accessibility': 3.1.1(react@18.3.1)
|
||||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
tslib: 2.6.2
|
tslib: 2.6.2
|
||||||
|
|
||||||
'@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dnd-kit/core': 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
tslib: 2.6.2
|
tslib: 2.6.2
|
||||||
|
|||||||
Reference in New Issue
Block a user