feat: milestones

This commit is contained in:
Carlos Valente
2025-07-07 15:37:54 +02:00
committed by Carlos Valente
parent a7ae8598db
commit f2913971db
26 changed files with 610 additions and 77 deletions
@@ -232,9 +232,9 @@ export const useEntryActions = () => {
* Updates existing entry * Updates existing entry
*/ */
const updateEntry = useCallback( const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => { async (entry: Partial<OntimeEntry>) => {
try { try {
await updateEntryMutation(event); await updateEntryMutation(entry);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { import {
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
isOntimeMilestone,
MaybeString, MaybeString,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
@@ -16,6 +17,7 @@ import { cloneEvent } from '../../common/utils/clone';
import RundownDelay from './rundown-delay/RundownDelay'; import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent'; import RundownEvent from './rundown-event/RundownEvent';
import RundownMilestone from './rundown-milestone/RundownMilestone';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
export type EventItemActions = export type EventItemActions =
@@ -201,6 +203,16 @@ export default function RundownEntry({
); );
} else if (isOntimeDelay(data)) { } else if (isOntimeDelay(data)) {
return <RundownDelay data={data} hasCursor={hasCursor} />; return <RundownDelay data={data} hasCursor={hasCursor} />;
} else if (isOntimeMilestone(data)) {
return (
<RundownMilestone
colour={data.colour}
cue={data.cue}
entryId={data.id}
hasCursor={hasCursor}
title={data.title}
/>
);
} }
return null; return null;
} }
@@ -13,6 +13,7 @@ import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput'; import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventTextArea from './composite/EventTextArea';
import EntryEditorTextInput from './composite/EventTextInput'; import EntryEditorTextInput from './composite/EventTextInput';
import style from './EntryEditor.module.scss'; import style from './EntryEditor.module.scss';
@@ -132,6 +133,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
<SwatchSelect name='colour' value={block.colour} handleChange={handleSubmit} /> <SwatchSelect name='colour' value={block.colour} handleChange={handleSubmit} />
</div> </div>
<EntryEditorTextInput field='title' label='Title' initialValue={block.title} submitHandler={handleSubmit} /> <EntryEditorTextInput field='title' label='Title' initialValue={block.title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={block.note} submitHandler={handleSubmit} />
</div> </div>
<div className={style.column}> <div className={style.column}>
@@ -139,7 +141,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={block} /> <EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={block} />
</div> </div>
</div> </div>
); );
@@ -69,7 +69,7 @@ export default function EventEditor({ event }: EventEditorProps) {
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={event} /> <EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
</div> </div>
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
@@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { OntimeMilestone } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../common/components/input/input/Input';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventTextArea from './composite/EventTextArea';
import EntryEditorTextInput from './composite/EventTextInput';
import style from './EntryEditor.module.scss';
// cue + title + colour + custom field labels
export type MilestoneEditorUpdateTextFields = 'cue' | 'title' | 'colour' | string;
interface MilestoneEditorProps {
milestone: OntimeMilestone;
}
export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions();
const handleSubmit = useCallback(
(field: MilestoneEditorUpdateTextFields, value: string) => {
// Handle custom fields
if (typeof field === 'string' && field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: milestone.id, custom: { [fieldLabel]: value } });
return;
}
// all other strings are text fields
return updateEntry({ id: milestone.id, [field]: value });
},
[milestone.id, updateEntry],
);
const isEditor = window.location.pathname.includes('editor');
return (
<div className={style.content}>
<div className={style.column}>
<Editor.Title>Milestone data</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='entryId'>Milestone ID (read only)</Editor.Label>
<Input id='entryId' data-testid='input-textfield' value={milestone.id} readOnly fluid />
</div>
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={milestone.cue}
submitHandler={handleSubmit}
maxLength={10}
/>
</div>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={milestone.colour} handleChange={handleSubmit} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={milestone.title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={milestone.note} submitHandler={handleSubmit} />
</div>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
</div>
</div>
);
}
@@ -1,5 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isOntimeBlock, isOntimeDelay, OntimeBlock, OntimeEvent } from 'ontime-types'; import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeBlock,
OntimeEvent,
OntimeMilestone,
} from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -8,6 +16,7 @@ import EventEditorFooter from './composite/EventEditorFooter';
import BlockEditor from './BlockEditor'; import BlockEditor from './BlockEditor';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
import EventEditorEmpty from './EventEditorEmpty'; import EventEditorEmpty from './EventEditorEmpty';
import MilestoneEditor from './MilestoneEditor';
import style from './EntryEditor.module.scss'; import style from './EntryEditor.module.scss';
@@ -15,44 +24,56 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | OntimeBlock | null>(null); const [entry, setEntry] = useState<OntimeEvent | OntimeBlock | OntimeMilestone | null>(null);
useEffect(() => { useEffect(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
setEvent(null); setEntry(null);
return; return;
} }
const selectedEventId = Array.from(selectedEvents).at(0); const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) { if (!selectedEventId) {
setEvent(null); setEntry(null);
return; return;
} }
const event = data.entries[selectedEventId]; const event = data.entries[selectedEventId];
if (event && !isOntimeDelay(event)) { if (event && !isOntimeDelay(event)) {
setEvent(event); setEntry(event);
} else { } else {
setEvent(null); setEntry(null);
} }
}, [data.order, data.entries, selectedEvents]); }, [data.order, data.entries, selectedEvents]);
if (!event) { if (!entry) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
} }
if (isOntimeBlock(event)) { if (isOntimeEvent(entry)) {
return ( return (
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<BlockEditor block={event} /> <EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} />
</div> </div>
); );
} }
return ( if (isOntimeMilestone(entry)) {
<div className={style.entryEditor} data-testid='editor-container'> return (
<EventEditor event={event} /> <div className={style.entryEditor} data-testid='editor-container'>
<EventEditorFooter id={event.id} cue={event.cue} /> <MilestoneEditor milestone={entry} />
</div> </div>
); );
}
if (isOntimeBlock(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<BlockEditor block={entry} />
</div>
);
}
return null;
} }
@@ -1,5 +1,5 @@
import { CSSProperties, Fragment } from 'react'; import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeBlock, OntimeEvent } from 'ontime-types'; import { CustomFields, OntimeBlock, OntimeEvent, OntimeMilestone } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EventEditorUpdateFields } from '../EventEditor'; import { EventEditorUpdateFields } from '../EventEditor';
@@ -12,21 +12,21 @@ import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps { interface EntryEditorCustomFieldsProps {
fields: CustomFields; fields: CustomFields;
event: OntimeEvent | OntimeBlock; entry: OntimeEvent | OntimeBlock | OntimeMilestone;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void; handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
} }
export default function EntryEditorCustomFields({ export default function EntryEditorCustomFields({
fields: customFields, fields: customFields,
handleSubmit, handleSubmit,
event, entry,
}: EntryEditorCustomFieldsProps) { }: EntryEditorCustomFieldsProps) {
return ( return (
<Fragment> <Fragment>
{Object.keys(customFields).map((fieldKey) => { {Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`; const key = `${entry.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`; const fieldName = `custom-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? ''; const initialValue = entry.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label; const labelText = customFields[fieldKey].label;
@@ -44,6 +44,16 @@ function QuickAddBlock({ previousEventId, parentBlock, backgroundColor }: QuickA
); );
}; };
const addMilestone = () => {
addEntry(
{ type: SupportedEntry.Milestone, parent: parentBlock },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
const addBlock = () => { const addBlock = () => {
if (parentBlock !== null) { if (parentBlock !== null) {
return; return;
@@ -76,6 +86,11 @@ function QuickAddBlock({ previousEventId, parentBlock, backgroundColor }: QuickA
Delay Delay
</Toolbar.Button> </Toolbar.Button>
<Toolbar.Button render={<Button size='small' variant='subtle-white' />} onClick={addMilestone}>
<IoAdd />
Milestone
</Toolbar.Button>
{parentBlock === null && ( {parentBlock === null && (
<Toolbar.Button render={<Button size='small' variant='subtle-white' />} onClick={addBlock}> <Toolbar.Button render={<Button size='small' variant='subtle-white' />} onClick={addBlock}>
<IoAdd /> <IoAdd />
@@ -0,0 +1,31 @@
@use '../blockMixins' as *;
.milestone {
@include block-styling;
margin-left: calc(2rem + 1px); // binder + border
margin-block: 0.125rem;
padding-right: 0.25rem;
background-color: color-mix(in srgb, var(--user-bg, $block-bg) 15%, transparent 85%);
display: grid;
grid-template-columns: 2rem 8rem 1fr auto auto;
align-items: center;
height: $secondary-block-height;
gap: 0.5rem;
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
}
.binder {
height: 100%;
display: grid;
place-content: center;
background-color: var(--user-bg, $block-bg);
}
.drag {
@include drag-style;
}
@@ -0,0 +1,131 @@
import { MouseEvent, useCallback, useRef } from 'react';
import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EntryId } from 'ontime-types';
import Button from '../../../common/components/buttons/Button';
import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
import style from './RundownMilestone.module.scss';
interface RundownMilestoneProps {
colour: string;
cue: string;
entryId: EntryId;
hasCursor: boolean;
title: string;
}
export default function RundownMilestone({ colour, cue, entryId, hasCursor, title }: RundownMilestoneProps) {
const handleRef = useRef<null | HTMLSpanElement>(null);
const { updateEntry, deleteEntry } = useEntryActions();
const { selectedEvents, setSelectedBlock } = useEventSelection();
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
isDragging,
transform,
transition,
} = useSortable({
id: entryId,
data: {
type: 'milestone',
},
animateLayoutChanges: () => false,
});
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
// event.button === 2 is a right-click
// disable selection if the user selected events and right clicks
// so the context menu shows up
if (selectedEvents.size > 1 && event.button === 2) {
return;
}
// UI indexes are 1 based
setSelectedBlock({ id: entryId });
};
const handleUpdate = (field: 'cue' | 'title', value: string) => {
updateEntry({ id: entryId, [field]: value });
};
const handleDelete = () => {
deleteEntry([entryId]);
};
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
};
const binderColours = colour && getAccessibleColour(colour);
return (
<div
className={cx([style.milestone, hasCursor ? style.hasCursor : null])}
ref={setNodeRef}
onClick={handleFocusClick}
style={{ ...dragStyle, '--user-bg': colour }}
data-testid='rundown-milestone'
>
<div className={style.binder}>
<span
className={style.drag}
style={{ ...binderColours }}
ref={handleRef}
{...dragAttributes}
{...dragListeners}
>
<IoReorderTwo />
</span>
</div>
<MilestoneTextInput field='cue' initialValue={cue} placeholder='Cue' submitHandler={handleUpdate} />
<MilestoneTextInput field='title' initialValue={title} placeholder='Title' submitHandler={handleUpdate} />
<Button variant='ghosted-white'>
<IoCheckmarkDone /> Done
</Button>
<Button variant='ghosted-destructive' onClick={handleDelete}>
<IoClose /> Cancel
</Button>
</div>
);
}
interface MilestoneTextInputProps {
field: 'cue' | 'title';
initialValue: string;
placeholder?: string;
submitHandler: (field: 'cue' | 'title', value: string) => void;
}
function MilestoneTextInput({ field, initialValue, placeholder, submitHandler }: MilestoneTextInputProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnEnter: true,
});
return (
<Input
id={field}
ref={ref}
fluid
value={value}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
);
}
@@ -7,6 +7,7 @@ import {
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeMilestone,
PlayableEvent, PlayableEvent,
RundownEntries, RundownEntries,
SupportedEntry, SupportedEntry,
@@ -83,10 +84,10 @@ function processEntry(
processedData.groupColour = entry.colour; processedData.groupColour = entry.colour;
} else { } else {
// for delays and blocks, we insert the group metadata // for delays and blocks, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) { if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
// if the parent is not the current group, we need to update the groupId // if the parent is not the current group, we need to update the groupId
processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent; processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
if ((entry as OntimeEvent | OntimeDelay).parent === null) { if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) {
// if the entry has no parent, it cannot have a group colour // if the entry has no parent, it cannot have a group colour
processedData.groupColour = undefined; processedData.groupColour = undefined;
} }
@@ -1,7 +1,15 @@
import { RefObject, useEffect } from 'react'; import { RefObject, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { RowModel, Table } from '@tanstack/react-table'; import { RowModel, Table } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types'; import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeBlock,
OntimeEntry,
Rundown,
} from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { RUNDOWN } from '../../../../common/api/constants'; import { RUNDOWN } from '../../../../common/api/constants';
@@ -13,6 +21,7 @@ import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import BlockRow from './BlockRow'; import BlockRow from './BlockRow';
import DelayRow from './DelayRow'; import DelayRow from './DelayRow';
import EventRow from './EventRow'; import EventRow from './EventRow';
import MilestoneRow from './MilestoneRow';
import { cleanup } from './rowObserver'; import { cleanup } from './rowObserver';
interface CuesheetBodyProps { interface CuesheetBodyProps {
@@ -90,6 +99,41 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
} }
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />; return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
} }
if (isOntimeMilestone(entry)) {
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (entry.colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
rowBgColour = colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
let parentBgColour: string | null = null;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
}
return (
<MilestoneRow
key={key}
isPast={isPast}
parentBgColour={parentBgColour}
rowBgColour={rowBgColour}
rowId={row.id}
table={table}
/>
);
}
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
eventIndex++; eventIndex++;
const isSelected = key === selectedEventId; const isSelected = key === selectedEventId;
@@ -0,0 +1,17 @@
@import "../CuesheetTable.module.scss";
.milestoneRow {
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
border-left: 4px solid var(--user-bg, $gray-500);
&:hover {
outline: 1px solid $blue-500;
outline-offset: -1px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
td {
background-color: $gray-1250;
border-radius: 2px;
}
}
@@ -0,0 +1,71 @@
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, enDash } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import style from './MilestoneRow.module.scss';
interface MilestoneRowProps {
isPast: boolean;
parentBgColour: string | null;
rowBgColour?: string;
rowId: string;
table: Table<OntimeEntry>;
}
export default function MilestoneRow({ isPast, parentBgColour, rowBgColour, rowId, table }: MilestoneRowProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const [cuesheetMode] = useSessionStorage<AppMode>({
key: sessionKeys.cuesheetMode,
defaultValue: AppMode.Edit,
});
return (
<tr
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
style={{
opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent',
}}
data-testid='cuesheet-milestone'
>
{cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton aria-label='Options' variant='subtle-white' size='small' onClick={() => undefined}>
<IoEllipsisHorizontal />
</IconButton>
</td>
)}
{!hideIndexColumn && (
<td className={style.indexColumn} tabIndex={-1} role='cell'>
{enDash}
</td>
)}
{table
.getRow(rowId)
.getVisibleCells()
.map((cell) => {
const canRender =
cell.column.id !== 'duration' && cell.column.id !== 'timeStart' && cell.column.id !== 'timeEnd';
return (
<td
key={cell.id}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour,
}}
tabIndex={-1}
role='cell'
>
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
@@ -4,8 +4,6 @@ import {
ProjectRundowns, ProjectRundowns,
Rundown, Rundown,
OntimeEvent, OntimeEvent,
OntimeDelay,
OntimeBlock,
isOntimeEvent, isOntimeEvent,
isOntimeDelay, isOntimeDelay,
isOntimeBlock, isOntimeBlock,
@@ -15,6 +13,7 @@ import {
PlayableEvent, PlayableEvent,
RundownEntries, RundownEntries,
isPlayableEvent, isPlayableEvent,
isOntimeMilestone,
} from 'ontime-types'; } from 'ontime-types';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
@@ -22,7 +21,7 @@ import { defaultRundown } from '../../models/dataModel.js';
import { delay as delayDef } from '../../models/eventsDefinition.js'; import { delay as delayDef } from '../../models/eventsDefinition.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js'; import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent, createMilestone } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js'; import { RundownMetadata } from './rundown.types.js';
/** /**
@@ -93,7 +92,7 @@ export function parseRundown(
} }
const id = entryId; const id = entryId;
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; let newEvent: OntimeEntry | null;
const nestedEntryIds: string[] = []; const nestedEntryIds: string[] = [];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
@@ -108,13 +107,17 @@ export function parseRundown(
eventIndex += 1; eventIndex += 1;
} else if (isOntimeDelay(event)) { } else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id }; newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeMilestone(event)) {
newEvent = createMilestone({ ...event, id });
cleanupCustomFields(newEvent.custom, parsedCustomFields);
} else if (isOntimeBlock(event)) { } else if (isOntimeBlock(event)) {
for (let i = 0; i < event.entries.length; i++) { for (let i = 0; i < event.entries.length; i++) {
const nestedEventId = event.entries[i]; const nestedEventId = event.entries[i];
const nestedEvent = rundown.entries[nestedEventId]; const nestedEvent = rundown.entries[nestedEventId];
let newNestedEvent: OntimeEntry | null = null;
if (isOntimeEvent(nestedEvent)) { if (isOntimeEvent(nestedEvent)) {
const newNestedEvent = createEvent(nestedEvent, eventIndex); newNestedEvent = createEvent(nestedEvent, eventIndex);
// skip if event is invalid // skip if event is invalid
if (newNestedEvent == null) { if (newNestedEvent == null) {
emitError?.('Skipping event without payload'); emitError?.('Skipping event without payload');
@@ -123,11 +126,16 @@ export function parseRundown(
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields); cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1; eventIndex += 1;
} else if (isOntimeDelay(nestedEvent)) {
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id };
} else if (isOntimeMilestone(nestedEvent)) {
newNestedEvent = createMilestone({ ...nestedEvent, id });
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
}
if (newNestedEvent) { if (newNestedEvent) {
nestedEntryIds.push(nestedEventId); nestedEntryIds.push(nestedEventId);
parsedRundown.entries[nestedEventId] = newNestedEvent; parsedRundown.entries[nestedEventId] = newNestedEvent;
}
} }
} }
@@ -151,7 +159,7 @@ export function parseRundown(
} }
} }
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`); console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.flatOrder.length} entries`);
return parsedRundown; return parsedRundown;
} }
@@ -247,7 +247,6 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b
throw new Error('Event not found'); throw new Error('Event not found');
} }
console.log('reorder', eventFrom.id, eventTo.id, order);
rundownMutation.reorder(rundown, eventFrom, eventTo, order); rundownMutation.reorder(rundown, eventFrom, eventTo, order);
const { rundown: rundownResult, rundownMetadata, revision } = commit(); const { rundown: rundownResult, rundownMetadata, revision } = commit();
@@ -5,11 +5,13 @@ import {
isOntimeBlock, isOntimeBlock,
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
isOntimeMilestone,
OntimeBaseEvent, OntimeBaseEvent,
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeMilestone,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeStrategy, TimeStrategy,
@@ -23,7 +25,12 @@ import {
validateTimes, validateTimes,
} from 'ontime-utils'; } from 'ontime-utils';
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; import {
event as eventDef,
block as blockDef,
delay as delayDef,
milestone as milestoneDef,
} from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js'; import { makeString } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js'; import { RundownMetadata } from './rundown.types.js';
@@ -34,16 +41,16 @@ type CompleteEntry<T> =
? OntimeDelay ? OntimeDelay
: T extends Partial<OntimeBlock> : T extends Partial<OntimeBlock>
? OntimeBlock ? OntimeBlock
: never; : T extends Partial<OntimeMilestone>
? OntimeMilestone
: never;
/** /**
* Generates a fully formed RundownEntry of the patch type * Generates a fully formed RundownEntry of the patch type
*/ */
export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>( export function generateEvent<
rundown: Rundown, T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock> | Partial<OntimeMilestone>,
eventData: T, >(rundown: Rundown, eventData: T, afterId: EntryId | null): CompleteEntry<T> {
afterId: EntryId | null,
): CompleteEntry<T> {
if (isOntimeEvent(eventData)) { if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry<T>; return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry<T>;
} }
@@ -59,6 +66,10 @@ export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDel
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>; return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
} }
if (isOntimeMilestone(eventData)) {
return createMilestone({ ...eventData, id }) as CompleteEntry<T>;
}
throw new Error('Invalid event type'); throw new Error('Invalid event type');
} }
@@ -136,25 +147,52 @@ export function createBlockPatch(originalBlock: OntimeBlock, patchBlock: Partial
}; };
} }
export function createMilestonePatch(
originalMilestone: OntimeMilestone,
patchMilestone: Partial<OntimeMilestone>,
): OntimeMilestone {
if (Object.keys(patchMilestone).length === 0) {
return originalMilestone;
}
return {
id: originalMilestone.id,
type: SupportedEntry.Milestone,
cue: makeString(patchMilestone.cue ?? null, originalMilestone.cue),
title: makeString(patchMilestone.title, originalMilestone.title),
note: makeString(patchMilestone.note, originalMilestone.note),
colour: makeString(patchMilestone.colour, originalMilestone.colour),
revision: originalMilestone.revision,
custom: { ...originalMilestone.custom, ...patchMilestone.custom },
parent: originalMilestone.parent,
};
}
/** /**
* Utility function for patching an existing event with new data * Utility function for patching an existing event with new data
* Increments the revision of the event when applying the patch * Increments the revision of the event when applying the patch
*/ */
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T { export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(eventFromRundown)) { if (isOntimeEvent(eventFromRundown)) {
const newEvent = createEventPatch(eventFromRundown, patch as Partial<OntimeEvent>); const newEvent = createEventPatch(eventFromRundown as OntimeEvent, patch as Partial<OntimeEvent>);
newEvent.revision++; newEvent.revision++;
return newEvent as T; return newEvent;
} }
if (isOntimeBlock(eventFromRundown)) { if (isOntimeBlock(eventFromRundown)) {
const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial<OntimeBlock>); const newBlock = createBlockPatch(eventFromRundown as OntimeBlock, patch as Partial<OntimeBlock>);
newBlock.revision++; newBlock.revision++;
return newBlock as T; return newBlock;
}
if (isOntimeMilestone(eventFromRundown)) {
const newMilestone = createMilestonePatch(eventFromRundown as OntimeMilestone, patch as Partial<OntimeMilestone>);
newMilestone.revision++;
return newMilestone;
} }
// only delay is left // only delay is left
return { ...eventFromRundown, ...patch } as T; return { ...eventFromRundown, ...patch } as OntimeDelay;
} }
/** /**
@@ -205,6 +243,27 @@ export function createBlock(patch?: Partial<OntimeBlock>): OntimeBlock {
}; };
} }
/**
* Creates a new milestone from an optional patch
*/
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
if (!patch) {
return { ...milestoneDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Milestone,
cue: patch.cue ?? '',
title: patch.title ?? '',
note: patch.note ?? '',
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
parent: patch.parent ?? null,
revision: 0,
};
}
/** /**
* Function infers strategy for a patch with only partial timer data * Function infers strategy for a patch with only partial timer data
* @param end * @param end
@@ -317,6 +376,15 @@ export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
return newEntry; return newEntry;
} }
/**
* Gathers business logic for how to clone an OntimeMilestone
*/
export function cloneMilestone(entry: OntimeMilestone, newId: EntryId): OntimeMilestone {
const newEntry = structuredClone(entry);
newEntry.id = newId;
return newEntry;
}
/** /**
* Gathers business logic for how to clone an OntimeBlock * Gathers business logic for how to clone an OntimeBlock
*/ */
@@ -333,13 +401,15 @@ export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
/** /**
* Receives an entry and chooses the correct cloning strategy * Receives an entry and chooses the correct cloning strategy
*/ */
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T { export function cloneEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId) as T; return cloneEvent(entry, newId);
} else if (isOntimeDelay(entry)) { } else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId) as T; return cloneDelay(entry, newId);
} else if (entry.type === 'block') { } else if (isOntimeBlock(entry)) {
return cloneBlock(entry as OntimeBlock, newId) as T; return cloneBlock(entry, newId);
} else if (isOntimeMilestone(entry)) {
return cloneMilestone(entry, newId);
} }
throw new Error(`Unsupported entry type for cloning: ${entry}`); throw new Error(`Unsupported entry type for cloning: ${entry}`);
} }
@@ -2,7 +2,7 @@ import { body, param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const rundownPostValidator = [ export const rundownPostValidator = [
body('type').isString().isIn(['event', 'delay', 'block']), body('type').isString().isIn(['event', 'delay', 'block', 'milestone']),
body('after').optional().isString(), body('after').optional().isString(),
body('before').optional().isString(), body('before').optional().isString(),
@@ -3,6 +3,7 @@ import {
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEvent, OntimeEvent,
OntimeMilestone,
SupportedEntry, SupportedEntry,
TimeStrategy, TimeStrategy,
TimerType, TimerType,
@@ -40,6 +41,18 @@ export const delay: Omit<OntimeDelay, 'id'> = {
parent: null, parent: null,
}; };
export const milestone: Omit<OntimeMilestone, 'id'> = {
type: SupportedEntry.Milestone,
cue: '',
title: '',
note: '',
colour: '',
custom: {},
parent: null,
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
};
export const block: Omit<OntimeBlock, 'id'> = { export const block: Omit<OntimeBlock, 'id'> = {
type: SupportedEntry.Block, type: SupportedEntry.Block,
title: '', title: '',
@@ -1,4 +1,4 @@
import type { OntimeEntry } from '../../definitions/core/Rundown.type.js'; import type { OntimeEntry } from '../../definitions/core/OntimeEntry.js';
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: string }; export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: string };
@@ -6,6 +6,7 @@ export enum SupportedEntry {
Event = 'event', Event = 'event',
Delay = 'delay', Delay = 'delay',
Block = 'block', Block = 'block',
Milestone = 'milestone',
} }
export type OntimeBaseEvent = { export type OntimeBaseEvent = {
@@ -19,6 +20,18 @@ export type OntimeDelay = OntimeBaseEvent & {
parent: EntryId | null; parent: EntryId | null;
}; };
export type OntimeMilestone = OntimeBaseEvent & {
type: SupportedEntry.Milestone;
cue: string;
title: string;
note: string;
colour: string;
custom: EntryCustomFields;
parent: EntryId | null;
// !==== RUNTIME METADATA ====! //
revision: number;
};
export type OntimeBlock = OntimeBaseEvent & { export type OntimeBlock = OntimeBaseEvent & {
type: SupportedEntry.Block; type: SupportedEntry.Block;
title: string; title: string;
@@ -65,3 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & {
export type PlayableEvent = OntimeEvent & { skip: false }; export type PlayableEvent = OntimeEvent & { skip: false };
export type TimeField = 'timeStart' | 'timeEnd' | 'duration'; export type TimeField = 'timeStart' | 'timeEnd' | 'duration';
export type OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent | OntimeMilestone;
// we need to create a manual union type since keys cannot be used in type unions
export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock | keyof OntimeMilestone;
@@ -1,11 +1,7 @@
import type { EntryId, OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js'; import type { EntryId, OntimeEntry } from './OntimeEntry.js';
export type OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent;
export type RundownEntries = Record<EntryId, OntimeEntry>; export type RundownEntries = Record<EntryId, OntimeEntry>;
// we need to create a manual union type since keys cannot be used in type unions
export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock;
type RundownId = string; type RundownId = string;
export type ProjectRundowns = Record<RundownId, Rundown>; export type ProjectRundowns = Record<RundownId, Rundown>;
@@ -1,5 +1,5 @@
import type { MaybeNumber } from '../../utils/utils.type.js'; import type { MaybeNumber } from '../../utils/utils.type.js';
import type { EntryId } from '../core/OntimeEvent.type.js'; import type { EntryId } from '../core/OntimeEntry.js';
export type BlockState = { export type BlockState = {
id: EntryId; id: EntryId;
@@ -1,4 +1,4 @@
import type { OntimeEvent } from '../core/OntimeEvent.type.js'; import type { OntimeEvent } from '../core/OntimeEntry.js';
import type { SimpleTimerState } from './AuxTimer.type.js'; import type { SimpleTimerState } from './AuxTimer.type.js';
import type { BlockState } from './CurrentBlockState.type.js'; import type { BlockState } from './CurrentBlockState.type.js';
import type { MessageState } from './MessageControl.type.js'; import type { MessageState } from './MessageControl.type.js';
+6 -8
View File
@@ -8,18 +8,15 @@ export {
type OntimeBaseEvent, type OntimeBaseEvent,
type OntimeDelay, type OntimeDelay,
type OntimeBlock, type OntimeBlock,
type OntimeEntryCommonKeys,
type OntimeEntry,
type OntimeMilestone,
type OntimeEvent, type OntimeEvent,
type PlayableEvent, type PlayableEvent,
type TimeField, type TimeField,
SupportedEntry as SupportedEntry, SupportedEntry as SupportedEntry,
} from './definitions/core/OntimeEvent.type.js'; } from './definitions/core/OntimeEntry.js';
export type { export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
OntimeEntryCommonKeys,
OntimeEntry,
RundownEntries,
Rundown,
ProjectRundowns,
} from './definitions/core/Rundown.type.js';
export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js';
export { TimerType } from './definitions/TimerType.type.js'; export { TimerType } from './definitions/TimerType.type.js';
@@ -114,6 +111,7 @@ export {
isOntimeBlock, isOntimeBlock,
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
isOntimeMilestone,
isPlayableEvent, isPlayableEvent,
isKeyOfType, isKeyOfType,
isOSCOutput, isOSCOutput,
+13 -3
View File
@@ -1,7 +1,13 @@
import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js'; import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js';
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js'; import type {
import { SupportedEntry } from '../definitions/core/OntimeEvent.type.js'; OntimeBlock,
import type { OntimeEntry } from '../definitions/core/Rundown.type.js'; OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeMilestone,
PlayableEvent,
} from '../definitions/core/OntimeEntry.js';
import { SupportedEntry } from '../definitions/core/OntimeEntry.js';
import { type TimerLifeCycle, timerLifecycleValues } from '../definitions/core/TimerLifecycle.type.js'; import { type TimerLifeCycle, timerLifecycleValues } from '../definitions/core/TimerLifecycle.type.js';
type MaybeEvent = OntimeEntry | Partial<OntimeEntry> | null | undefined; type MaybeEvent = OntimeEntry | Partial<OntimeEntry> | null | undefined;
@@ -22,6 +28,10 @@ export function isOntimeBlock(event: MaybeEvent): event is OntimeBlock {
return event?.type === SupportedEntry.Block; return event?.type === SupportedEntry.Block;
} }
export function isOntimeMilestone(event: MaybeEvent): event is OntimeMilestone {
return event?.type === SupportedEntry.Milestone;
}
type AnyKeys<T> = keyof T; type AnyKeys<T> = keyof T;
export function isKeyOfType<T extends object>(key: PropertyKey, obj: T): key is AnyKeys<T> { export function isKeyOfType<T extends object>(key: PropertyKey, obj: T): key is AnyKeys<T> {