mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +00:00
feat: milestones
This commit is contained in:
committed by
Carlos Valente
parent
a7ae8598db
commit
f2913971db
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-1
@@ -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;
|
||||||
|
|||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
+17
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user