mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-05 22:39:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 972a246bb6 |
@@ -1,5 +0,0 @@
|
|||||||
# CodeGraph data files — local to each machine, not for committing.
|
|
||||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
|
||||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
"patterns": ["ontime-types/src/*", "ontime-utils/src/*", "zod"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.11.0",
|
"version": "4.10.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.11.0",
|
"version": "4.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ProjectRundownsList,
|
ProjectRundownsList,
|
||||||
RenumberCues,
|
RenumberCues,
|
||||||
Rundown,
|
Rundown,
|
||||||
RundownImportPayload,
|
|
||||||
TransientEventPayload,
|
TransientEventPayload,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
|
|||||||
return axios.delete(`${rundownPath}/${rundownId}`);
|
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
|
|
||||||
*/
|
|
||||||
export async function importRundownWithOptions(
|
|
||||||
payload: RundownImportPayload,
|
|
||||||
): Promise<AxiosResponse<ProjectRundownsList>> {
|
|
||||||
return axios.post(`${rundownPath}/import`, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
.kbd {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 1.5rem;
|
|
||||||
padding: 0.0625rem 0.375rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
background-color: $gray-1100;
|
|
||||||
color: $ui-white;
|
|
||||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: calc(1rem - 4px);
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
import style from './Kbd.module.scss';
|
|
||||||
|
|
||||||
export default function Kbd({ children }: PropsWithChildren) {
|
|
||||||
return <kbd className={style.kbd}>{children}</kbd>;
|
|
||||||
}
|
|
||||||
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
|
|||||||
.multiprogress-bar__indicator-bar {
|
.multiprogress-bar__indicator-bar {
|
||||||
background-color: var(--background-color-override, $ui-black);
|
background-color: var(--background-color-override, $ui-black);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
transition: 1s linear;
|
||||||
|
transition-property: width;
|
||||||
|
|
||||||
.multiprogress-bar--ignore-css-override & {
|
.multiprogress-bar--ignore-css-override & {
|
||||||
background-color: $ui-black;
|
background-color: $ui-black;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
|
|
||||||
import { getProgress } from '../../utils/getProgress';
|
import { getProgress } from '../../utils/getProgress';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
|||||||
className = '',
|
className = '',
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const percentRemaining = 100 - useAnimatedProgress(now, complete);
|
const percentRemaining = 100 - getProgress(now, complete);
|
||||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||||
const isOvertime = now !== null && now < 0;
|
const isOvertime = now !== null && now < 0;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '../../../stores/savedViewParams';
|
} from '../../../stores/savedViewParams';
|
||||||
import { handleLinks } from '../../../utils/linkUtils';
|
import { handleLinks } from '../../../utils/linkUtils';
|
||||||
import IconButton from '../../buttons/IconButton';
|
import IconButton from '../../buttons/IconButton';
|
||||||
import Tooltip from '../../tooltip/Tooltip';
|
|
||||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||||
|
|
||||||
import style from './ClientLink.module.scss';
|
import style from './ClientLink.module.scss';
|
||||||
@@ -106,20 +105,16 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
|
|||||||
{isCustomised && (
|
{isCustomised && (
|
||||||
<span className={style.trailing}>
|
<span className={style.trailing}>
|
||||||
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
||||||
<Tooltip
|
<IconButton
|
||||||
text='Reset to default'
|
variant='ghosted-white'
|
||||||
render={
|
size='small'
|
||||||
<IconButton
|
className={style.clear}
|
||||||
variant='ghosted-white'
|
aria-label='Clear saved view settings'
|
||||||
size='small'
|
title='Clear saved view settings'
|
||||||
className={style.clear}
|
onClick={clearViewSettings}
|
||||||
aria-label='Reset to default'
|
|
||||||
onClick={clearViewSettings}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IoCloseOutline />
|
<IoCloseOutline />
|
||||||
</Tooltip>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
|
|||||||
.progress-bar__indicator {
|
.progress-bar__indicator {
|
||||||
height: $progress-bar-size;
|
height: $progress-bar-size;
|
||||||
background-color: var(--timer-progress-override, $accent-color);
|
background-color: var(--timer-progress-override, $accent-color);
|
||||||
|
transition: 1s linear;
|
||||||
|
transition-property: width;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
|
import { getProgress } from '../../utils/getProgress';
|
||||||
|
|
||||||
import './ProgressBar.scss';
|
import './ProgressBar.scss';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ interface ProgressBarProps {
|
|||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
export default function ProgressBar(props: ProgressBarProps) {
|
||||||
const { current, duration, className } = props;
|
const { current, duration, className } = props;
|
||||||
const progress = useAnimatedProgress(current, duration);
|
const progress = getProgress(current, duration);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`progress-bar__bg ${className}`}>
|
<div className={`progress-bar__bg ${className}`}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useSearchParams } from 'react-router';
|
|||||||
|
|
||||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
import Info from '../info/Info';
|
import Info from '../info/Info';
|
||||||
@@ -28,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
const { isOpen, close } = useViewParamsEditorStore();
|
const { isOpen, close } = useViewParamsEditorStore();
|
||||||
const clearSavedParams = useSavedViewParams((store) => store.clear);
|
|
||||||
const isSmallScreen = useIsSmallScreen();
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
|
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
|
||||||
@@ -38,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetParams = () => {
|
const resetParams = () => {
|
||||||
clearSavedParams(target);
|
|
||||||
setSearchParams(getPreservedParams());
|
setSearchParams(getPreservedParams());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { MaybeNumber, Playback } from 'ontime-types';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { getProgress } from '../utils/getProgress';
|
|
||||||
import { usePlayback } from './useSocket';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the live completion percentage (0–100) of a countdown, interpolated locally.
|
|
||||||
*/
|
|
||||||
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
|
|
||||||
const playback = usePlayback();
|
|
||||||
const isRunning = playback === Playback.Play || playback === Playback.Roll;
|
|
||||||
|
|
||||||
const baseline = useRef({ current, at: performance.now() });
|
|
||||||
const [, setTick] = useState(0);
|
|
||||||
|
|
||||||
// there is only something to animate while a running timer is counting down towards 0
|
|
||||||
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
|
|
||||||
|
|
||||||
// re-anchor to the authoritative value whenever the server pushes a new timer update
|
|
||||||
useEffect(() => {
|
|
||||||
baseline.current = { current, at: performance.now() };
|
|
||||||
}, [current, duration, playback]);
|
|
||||||
|
|
||||||
// while counting down, re-render every animation frame so the derived progress stays smooth
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldAnimate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let frame = requestAnimationFrame(function tick() {
|
|
||||||
setTick((value) => value + 1);
|
|
||||||
frame = requestAnimationFrame(tick);
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(frame);
|
|
||||||
}, [shouldAnimate]);
|
|
||||||
|
|
||||||
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
|
|
||||||
const anchored = baseline.current.current;
|
|
||||||
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
|
|
||||||
return getProgress(value, duration);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
EntryId,
|
EntryId,
|
||||||
|
InsertOptions,
|
||||||
MaybeString,
|
MaybeString,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -59,15 +60,9 @@ export type EventOptions = Partial<{
|
|||||||
before: MaybeString;
|
before: MaybeString;
|
||||||
// options of entries of type OntimeEvent
|
// options of entries of type OntimeEvent
|
||||||
linkPrevious: boolean;
|
linkPrevious: boolean;
|
||||||
/** the timing reference for the new entry, usually the same as after */
|
|
||||||
lastEventId: MaybeString;
|
lastEventId: MaybeString;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ClientInsertOptions = {
|
|
||||||
after?: EntryId;
|
|
||||||
before?: EntryId;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gather utilities for actions on entries in the loaded rundown.
|
* Gather utilities for actions on entries in the loaded rundown.
|
||||||
*/
|
*/
|
||||||
@@ -125,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: addEntryMutation } = useMutation({
|
const { mutateAsync: addEntryMutation } = useMutation({
|
||||||
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
|
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
|
||||||
onMutate: async ([_rundownId, entry]) => {
|
onMutate: async ([_rundownId, entry]) => {
|
||||||
const queryKey = resolveCurrentRundownQueryKey();
|
const queryKey = resolveCurrentRundownQueryKey();
|
||||||
await queryClient.cancelQueries({ queryKey });
|
await queryClient.cancelQueries({ queryKey });
|
||||||
@@ -154,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
addToRundown(
|
addToRundown(
|
||||||
newRundown,
|
newRundown,
|
||||||
optimisticEntry,
|
optimisticEntry,
|
||||||
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
|
|
||||||
afterId,
|
afterId,
|
||||||
entry.before ?? null,
|
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
queryClient.setQueryData<Rundown>(queryKey, newRundown);
|
queryClient.setQueryData<Rundown>(queryKey, newRundown);
|
||||||
@@ -199,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
throw new Error('Rundown not initialised');
|
throw new Error('Rundown not initialised');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
|
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
|
||||||
|
|
||||||
// handle adding options that concern all event types
|
// handle adding options that concern all event types
|
||||||
if (options?.after) {
|
if (options?.after) {
|
||||||
@@ -211,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEntry)) {
|
if (isOntimeEvent(newEntry)) {
|
||||||
// last event id is the timing reference of the previous OntimeEvent in rundown
|
|
||||||
if (options?.lastEventId) {
|
if (options?.lastEventId) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||||
if (isOntimeEvent(previousEvent)) {
|
if (isOntimeEvent(previousEvent)) {
|
||||||
newEntry.timeStart = previousEvent.timeEnd;
|
newEntry.timeStart = previousEvent.timeEnd;
|
||||||
@@ -290,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
* Clone an entry
|
* Clone an entry
|
||||||
*/
|
*/
|
||||||
const clone = useCallback(
|
const clone = useCallback(
|
||||||
async (entryId: EntryId, options?: ClientInsertOptions) => {
|
async (entryId: EntryId, options?: InsertOptions) => {
|
||||||
try {
|
try {
|
||||||
const rundownId = getCurrentRundownData()?.id;
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
if (!rundownId) {
|
if (!rundownId) {
|
||||||
@@ -1063,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
|||||||
/**
|
/**
|
||||||
* Utility to create an optimistic entry for immediate cache insertion
|
* Utility to create an optimistic entry for immediate cache insertion
|
||||||
*/
|
*/
|
||||||
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
|
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
|
||||||
const { after: _after, before: _before, ...entryData } = payload;
|
const { after: _after, before: _before, ...entryData } = payload;
|
||||||
const id = entryData.id;
|
const id = entryData.id;
|
||||||
let parent: EntryId | null = null;
|
let parent: EntryId | null = null;
|
||||||
|
|||||||
@@ -139,17 +139,6 @@ export const setEventPlayback = {
|
|||||||
pause: () => sendSocket('pause', undefined),
|
pause: () => sendSocket('pause', undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
|
|
||||||
playback: state.timer.playback,
|
|
||||||
phase: state.timer.phase,
|
|
||||||
addedTime: state.timer.addedTime,
|
|
||||||
secondaryTimer: state.timer.secondaryTimer,
|
|
||||||
current: state.timer.current,
|
|
||||||
expectedFinish: state.timer.expectedFinish,
|
|
||||||
startedAt: state.timer.startedAt,
|
|
||||||
isCountToEnd: state.eventNow?.countToEnd ?? false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useTimer = createSelector((state: RuntimeStore) => ({
|
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||||
...state.timer,
|
...state.timer,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { removeFileExtension } from '../uploadUtils';
|
|
||||||
|
|
||||||
describe('removeFileExtension()', () => {
|
|
||||||
it('removes a trailing extension', () => {
|
|
||||||
expect(removeFileExtension('show.xlsx')).toBe('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only removes the last extension', () => {
|
|
||||||
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the name unchanged when there is no extension', () => {
|
|
||||||
expect(removeFileExtension('rundown')).toBe('rundown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not treat a leading dot as an extension', () => {
|
|
||||||
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles an empty string', () => {
|
|
||||||
expect(removeFileExtension('')).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,9 +3,7 @@ import {
|
|||||||
MILLIS_PER_HOUR,
|
MILLIS_PER_HOUR,
|
||||||
MILLIS_PER_MINUTE,
|
MILLIS_PER_MINUTE,
|
||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
dayInMs,
|
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
getExpectedEnd,
|
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
|
|||||||
return elapsed;
|
return elapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normaliseWallClock(time: number): number {
|
|
||||||
const timeOfDay = time % dayInMs;
|
|
||||||
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Resolves format from url and store
|
* @description Resolves format from url and store
|
||||||
* @return {string|null} A format string like "hh:mm:ss a" or null
|
* @return {string|null} A format string like "hh:mm:ss a" or null
|
||||||
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
...state,
|
...state,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
|
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd,
|
expectedEnd: event.countToEnd
|
||||||
|
? Math.max(expectedStart + event.duration, plannedEnd)
|
||||||
|
: expectedStart + event.duration,
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
|
|
||||||
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
|
|
||||||
*/
|
|
||||||
export function removeFileExtension(fileName: string): string {
|
|
||||||
const lastDot = fileName.lastIndexOf('.');
|
|
||||||
if (lastDot <= 0) {
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
return fileName.slice(0, lastDot);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isExcelFile(file: File | null) {
|
export function isExcelFile(file: File | null) {
|
||||||
return file?.name.endsWith('.xlsx');
|
return file?.name.endsWith('.xlsx');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,9 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
<h3>Outputs</h3>
|
<h3>Outputs</h3>
|
||||||
<Info>
|
<Info>
|
||||||
Automation outputs can be used to send data from Ontime to external software <br />
|
Automation outputs can be used to send data from Ontime to external software <br />
|
||||||
or to change properties of Ontime itself. <br /> <br />
|
or to change properties of Ontime itself.
|
||||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
{fieldOutputs.map((output, index) => {
|
{fieldOutputs.map((output, index) => {
|
||||||
@@ -342,17 +341,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Address
|
Address
|
||||||
<TemplateInput
|
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
|
||||||
{...register(`outputs.${index}.address`)}
|
|
||||||
value={output.address}
|
|
||||||
fluid
|
|
||||||
placeholder='/cue/start'
|
|
||||||
/>
|
|
||||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Arguments
|
Arguments
|
||||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
|
||||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -382,7 +376,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
<div className={style.httpSection}>
|
<div className={style.httpSection}>
|
||||||
<label>
|
<label>
|
||||||
Target URL
|
Target URL
|
||||||
<TemplateInput
|
<Input
|
||||||
{...register(`outputs.${index}.url`, {
|
{...register(`outputs.${index}.url`, {
|
||||||
required: { value: true, message: 'Required field' },
|
required: { value: true, message: 'Required field' },
|
||||||
pattern: {
|
pattern: {
|
||||||
@@ -390,7 +384,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
message: 'HTTP messages should target http:// or https://',
|
message: 'HTTP messages should target http:// or https://',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
value={output.url}
|
|
||||||
fluid
|
fluid
|
||||||
placeholder='http://127.0.0.1/start/1'
|
placeholder='http://127.0.0.1/start/1'
|
||||||
/>
|
/>
|
||||||
|
|||||||
+28
-51
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
|||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import TemplateInput from './template-input/TemplateInput';
|
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
|
|||||||
{ value: 'playback-pause', label: 'Playback: pause' },
|
{ value: 'playback-pause', label: 'Playback: pause' },
|
||||||
{ value: 'playback-roll', label: 'Playback: roll' },
|
{ value: 'playback-roll', label: 'Playback: roll' },
|
||||||
|
|
||||||
{ value: 'message-set', label: 'Primary Message' },
|
{ value: 'message-set', label: 'Primary Message: set' },
|
||||||
{ value: 'message-secondary', label: 'Secondary Message' },
|
{ value: 'message-secondary', label: 'Secondary Message: source' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||||
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
Text (leave empty for no change)
|
Text (leave empty for no change)
|
||||||
<TemplateInput
|
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
|
||||||
{...register(`outputs.${index}.text`)}
|
|
||||||
value={watch(`outputs.${index}.text`) ?? ''}
|
|
||||||
fluid
|
|
||||||
placeholder='eg: Timer is finished'
|
|
||||||
/>
|
|
||||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAction === 'message-secondary' && (
|
{selectedAction === 'message-secondary' && (
|
||||||
<>
|
<label>
|
||||||
<label>
|
Timer secondary source
|
||||||
Text (leave empty for no change)
|
<Select<SecondarySource | 'null' | null>
|
||||||
<TemplateInput
|
onValueChange={(value) => {
|
||||||
{...register(`outputs.${index}.text`)}
|
// null -> no selection
|
||||||
value={watch(`outputs.${index}.text`) ?? ''}
|
if (value === null) return;
|
||||||
fluid
|
// 'null' -> clear the secondary source
|
||||||
placeholder='eg: Next up: keynote'
|
if (value === 'null') {
|
||||||
/>
|
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
return;
|
||||||
</label>
|
}
|
||||||
<label>
|
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||||
Timer secondary source
|
}}
|
||||||
<Select<SecondarySource | 'no-change' | 'null' | null>
|
value={watch(`outputs.${index}.secondarySource`)}
|
||||||
onValueChange={(value) => {
|
options={[
|
||||||
// null -> no selection
|
{ value: null, label: 'Select secondary source' },
|
||||||
if (value === null) return;
|
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||||
// no-change -> leave the current secondary source untouched
|
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||||
if (value === 'no-change') {
|
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||||
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
|
{ value: 'secondary', label: 'Secondary' },
|
||||||
return;
|
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||||
}
|
]}
|
||||||
// 'null' -> clear the secondary source
|
/>
|
||||||
if (value === 'null') {
|
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
</label>
|
||||||
return;
|
|
||||||
}
|
|
||||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
|
|
||||||
options={[
|
|
||||||
{ value: 'no-change', label: 'No change' },
|
|
||||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
|
||||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
|
||||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
|
||||||
{ value: 'secondary', label: 'Secondary' },
|
|
||||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={style.test}>{children}</div>
|
<div className={style.test}>{children}</div>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{!showForm && triggers.length === 0 && (
|
{!showForm && triggers.length === 0 && (
|
||||||
<Panel.TableEmpty
|
<Panel.TableEmpty
|
||||||
label='Create a trigger to run an automation'
|
label='Create an automation to attach triggers to'
|
||||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+17
-55
@@ -1,67 +1,29 @@
|
|||||||
.inputShell {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fluid {
|
.suggestions {
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
padding-right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 0.25rem;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.positioner {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup {
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-width: var(--anchor-width);
|
|
||||||
max-width: var(--available-width);
|
|
||||||
border: 1px solid $gray-1000;
|
|
||||||
border-radius: $component-border-radius-md;
|
|
||||||
background: $gray-1250;
|
background: $gray-1250;
|
||||||
color: $label-gray;
|
color: $ui-white;
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
position: absolute;
|
||||||
box-sizing: border-box;
|
top: 100%;
|
||||||
max-height: min(20rem, var(--available-height));
|
left: 0;
|
||||||
overflow-y: auto;
|
width: 100%;
|
||||||
overscroll-behavior: contain;
|
margin: 0;
|
||||||
|
z-index: $zindex-floating;
|
||||||
padding-block: 0.25rem;
|
padding-block: 0.25rem;
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
max-height: 200px;
|
||||||
box-sizing: border-box;
|
overflow-y: auto;
|
||||||
padding: 0.25rem 0.5rem;
|
color: $label-gray;
|
||||||
outline: 0;
|
|
||||||
cursor: default;
|
|
||||||
user-select: none;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
|
||||||
&[data-highlighted] {
|
li {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
background: $blue-700;
|
background: $blue-700;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.expandedEditor {
|
|
||||||
min-height: min(18rem, 45vh);
|
|
||||||
font-family: monospace;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerHint {
|
|
||||||
margin-right: auto;
|
|
||||||
color: $label-gray;
|
|
||||||
font-size: calc(1rem - 2px);
|
|
||||||
}
|
|
||||||
|
|||||||
+43
-232
@@ -1,258 +1,69 @@
|
|||||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
import { mergeRefs, useClickOutside } from '@mantine/hooks';
|
||||||
import type { ChangeEvent, ReactNode, Ref } from 'react';
|
import { forwardRef, useMemo, useState } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { IoExpandOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import Button from '../../../../../common/components/buttons/Button';
|
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
|
||||||
import { type InputProps } from '../../../../../common/components/input/input/Input';
|
|
||||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
|
||||||
import Modal from '../../../../../common/components/modal/Modal';
|
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import { cx } from '../../../../../common/utils/styleUtils';
|
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
|
||||||
import { makeAutoCompleteList } from './templateInput.utils';
|
|
||||||
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
|
|
||||||
|
|
||||||
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
|
|
||||||
import style from './TemplateInput.module.scss';
|
import style from './TemplateInput.module.scss';
|
||||||
|
|
||||||
interface TemplateInputProps extends Omit<InputProps, 'value'> {
|
interface TemplateInputProps extends InputProps {}
|
||||||
ref?: Ref<HTMLInputElement>;
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplateEditorModalProps {
|
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||||
autocompleteList: string[];
|
const { value, onChange, ...rest } = props;
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (value: string) => void;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
|
|
||||||
|
|
||||||
interface TemplateAutocompleteRootProps {
|
|
||||||
autocomplete: TemplateAutocompleteState;
|
|
||||||
children: ReactNode;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
|
||||||
if (typeof ref === 'function') {
|
|
||||||
ref(value);
|
|
||||||
} else if (ref) {
|
|
||||||
ref.current = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
|
|
||||||
onChange?.({
|
|
||||||
target: { name, value },
|
|
||||||
currentTarget: { name, value },
|
|
||||||
} as ChangeEvent<HTMLInputElement>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TemplateInput({
|
|
||||||
className,
|
|
||||||
disabled,
|
|
||||||
fluid,
|
|
||||||
height = 'medium',
|
|
||||||
onChange,
|
|
||||||
readOnly,
|
|
||||||
ref,
|
|
||||||
value,
|
|
||||||
variant = 'subtle',
|
|
||||||
...rest
|
|
||||||
}: TemplateInputProps) {
|
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const autocompleteList = useMemo(() => {
|
const autocompleteList = useMemo(() => {
|
||||||
return makeAutoCompleteList(data);
|
return makeAutoCompleteList(data);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const updateInputValue = useCallback(
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
(nextValue: string) => {
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
setInputValue(nextValue);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
emitInputChange(rest.name, nextValue, onChange);
|
|
||||||
},
|
|
||||||
[onChange, rest.name],
|
|
||||||
);
|
|
||||||
|
|
||||||
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
|
const updateSuggestions = (value: string) => {
|
||||||
const { setCursorForValue } = autocomplete;
|
const template = selectFromLastTemplate(value);
|
||||||
|
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
|
||||||
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
|
|
||||||
useEffect(() => {
|
|
||||||
const nextValue = value || '';
|
|
||||||
setInputValue(nextValue);
|
|
||||||
setCursorForValue(nextValue, nextValue.length);
|
|
||||||
}, [setCursorForValue, value]);
|
|
||||||
|
|
||||||
const setInputRef = useCallback(
|
|
||||||
(element: HTMLInputElement | null) => {
|
|
||||||
inputRef.current = element;
|
|
||||||
assignRef(ref, element);
|
|
||||||
},
|
|
||||||
[ref],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openExpandedEditor = () => {
|
|
||||||
autocomplete.setShowSuggestions(false);
|
|
||||||
setIsExpanded(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeExpandedEditor = () => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsExpanded(false);
|
setInputValue(event.target.value);
|
||||||
};
|
|
||||||
|
|
||||||
const saveExpandedEditor = (nextValue: string) => {
|
if (event.target.value.endsWith('{')) {
|
||||||
updateInputValue(nextValue);
|
setShowSuggestions(true);
|
||||||
autocomplete.setCursorForValue(nextValue, nextValue.length);
|
setSuggestions(updateSuggestions(event.target.value));
|
||||||
setIsExpanded(false);
|
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
||||||
};
|
setShowSuggestions(false);
|
||||||
|
} else if (showSuggestions) {
|
||||||
return (
|
setSuggestions(updateSuggestions(event.target.value));
|
||||||
<>
|
|
||||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
|
|
||||||
<div className={cx([style.inputShell, fluid && style.fluid])}>
|
|
||||||
<BaseAutocomplete.Input
|
|
||||||
ref={setInputRef}
|
|
||||||
className={cx([
|
|
||||||
inputStyle.input,
|
|
||||||
inputStyle[variant],
|
|
||||||
inputStyle[height],
|
|
||||||
fluid && inputStyle.fluid,
|
|
||||||
style.input,
|
|
||||||
className,
|
|
||||||
])}
|
|
||||||
{...rest}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={autocomplete.updateCursor}
|
|
||||||
onFocus={autocomplete.updateCursor}
|
|
||||||
onKeyUp={autocomplete.updateCursor}
|
|
||||||
onSelect={autocomplete.updateCursor}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Expand template editor'
|
|
||||||
className={style.expandButton}
|
|
||||||
disabled={disabled || readOnly}
|
|
||||||
onClick={openExpandedEditor}
|
|
||||||
size='small'
|
|
||||||
title='Expand template editor'
|
|
||||||
variant='ghosted-white'
|
|
||||||
>
|
|
||||||
<IoExpandOutline />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</TemplateAutocompleteRoot>
|
|
||||||
<TemplateEditorModal
|
|
||||||
autocompleteList={autocompleteList}
|
|
||||||
isOpen={isExpanded}
|
|
||||||
onClose={closeExpandedEditor}
|
|
||||||
onSave={saveExpandedEditor}
|
|
||||||
value={inputValue}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
|
|
||||||
return (
|
|
||||||
<BaseAutocomplete.Root
|
|
||||||
items={autocomplete.suggestions}
|
|
||||||
autoHighlight
|
|
||||||
highlightItemOnHover
|
|
||||||
mode='none'
|
|
||||||
open={autocomplete.open}
|
|
||||||
value={value}
|
|
||||||
onOpenChange={autocomplete.setShowSuggestions}
|
|
||||||
onValueChange={autocomplete.handleValueChange}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<TemplateSuggestionPopup />
|
|
||||||
</BaseAutocomplete.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
|
|
||||||
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
const [draftValue, setDraftValue] = useState(value);
|
|
||||||
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
|
|
||||||
const { setShowSuggestions } = autocomplete;
|
|
||||||
|
|
||||||
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraftValue(value);
|
onChange?.(event);
|
||||||
setShowSuggestions(false);
|
|
||||||
}, [isOpen, setShowSuggestions, value]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSuggestion = (value: string) => {
|
||||||
|
setInputValue((prev) => {
|
||||||
|
const remaining = matchRemaining(prev as string, value);
|
||||||
|
return prev + remaining;
|
||||||
|
});
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
onSave(draftValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
||||||
isOpen={isOpen}
|
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
|
||||||
onClose={handleClose}
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
showBackdrop
|
<ul className={style.suggestions}>
|
||||||
showCloseButton
|
{suggestions.map((suggestion) => (
|
||||||
title='Edit template'
|
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
|
||||||
bodyElements={
|
{suggestion}
|
||||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
|
</li>
|
||||||
<BaseAutocomplete.Input
|
))}
|
||||||
autoFocus
|
</ul>
|
||||||
className={style.expandedEditor}
|
)}
|
||||||
onClick={autocomplete.updateCursor}
|
</div>
|
||||||
onFocus={autocomplete.updateCursor}
|
|
||||||
onKeyUp={autocomplete.updateCursor}
|
|
||||||
onSelect={autocomplete.updateCursor}
|
|
||||||
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
|
|
||||||
/>
|
|
||||||
</TemplateAutocompleteRoot>
|
|
||||||
}
|
|
||||||
footerElements={
|
|
||||||
<>
|
|
||||||
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
|
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
|
||||||
<Button onClick={handleSave} variant='primary'>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function TemplateSuggestionPopup() {
|
export default TemplateInput;
|
||||||
return (
|
|
||||||
<BaseAutocomplete.Portal>
|
|
||||||
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
|
|
||||||
<BaseAutocomplete.Popup className={style.popup}>
|
|
||||||
<BaseAutocomplete.List className={style.list}>
|
|
||||||
<BaseAutocomplete.Collection>
|
|
||||||
{(suggestion: string) => (
|
|
||||||
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
|
|
||||||
{suggestion}
|
|
||||||
</BaseAutocomplete.Item>
|
|
||||||
)}
|
|
||||||
</BaseAutocomplete.Collection>
|
|
||||||
</BaseAutocomplete.List>
|
|
||||||
</BaseAutocomplete.Popup>
|
|
||||||
</BaseAutocomplete.Positioner>
|
|
||||||
</BaseAutocomplete.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-52
@@ -1,4 +1,4 @@
|
|||||||
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
|
import { matchRemaining } from '../templateInput.utils';
|
||||||
|
|
||||||
describe('matchRemaining()', () => {
|
describe('matchRemaining()', () => {
|
||||||
it('should return a partial string needed for autocomplete', () => {
|
it('should return a partial string needed for autocomplete', () => {
|
||||||
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
|
|||||||
expect(matchRemaining('test', 'test')).toBe('');
|
expect(matchRemaining('test', 'test')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('selectActiveTemplate()', () => {
|
|
||||||
it('returns the last unclosed template fragment', () => {
|
|
||||||
expect(selectActiveTemplate('send {{event')).toBe('{{event');
|
|
||||||
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores single braces and closed templates', () => {
|
|
||||||
expect(selectActiveTemplate('send {')).toBe('');
|
|
||||||
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only considers templates before the cursor', () => {
|
|
||||||
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
|
|
||||||
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects a partial template when the cursor is inside a completed template', () => {
|
|
||||||
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('completeTemplateAtCursor()', () => {
|
|
||||||
it('completes the active template before the cursor', () => {
|
|
||||||
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
|
|
||||||
value: 'send {{timer.current}} after',
|
|
||||||
cursorIndex: 22,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves text before and after the cursor', () => {
|
|
||||||
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
|
|
||||||
value: 'before {{eventNow.title}} after',
|
|
||||||
cursorIndex: 25,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves a following template when completing between templates', () => {
|
|
||||||
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
|
|
||||||
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
|
|
||||||
cursorIndex: 31,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces the whole template when the cursor is inside a completed template', () => {
|
|
||||||
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
|
|
||||||
value: 'before {{timer.duration}} after',
|
|
||||||
cursorIndex: 25,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+6
-64
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
|
|||||||
'{{eventNext.delay}}',
|
'{{eventNext.delay}}',
|
||||||
];
|
];
|
||||||
|
|
||||||
const groupStaticPropertiesNow = [
|
|
||||||
'{{groupNow.id}}',
|
|
||||||
'{{groupNow.title}}',
|
|
||||||
'{{groupNow.note}}',
|
|
||||||
'{{groupNow.colour}}',
|
|
||||||
'{{groupNow.timeStart}}',
|
|
||||||
'{{groupNow.timeEnd}}',
|
|
||||||
'{{groupNow.duration}}',
|
|
||||||
];
|
|
||||||
|
|
||||||
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||||
`{{auxtimer${index}.current}}`,
|
`{{auxtimer${index}.current}}`,
|
||||||
`{{auxtimer${index}.duration}}`,
|
`{{auxtimer${index}.duration}}`,
|
||||||
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
|||||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||||
...eventStaticPropertiesNext,
|
...eventStaticPropertiesNext,
|
||||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||||
...groupStaticPropertiesNow,
|
|
||||||
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
|
|
||||||
...staticAuxProperties(1),
|
...staticAuxProperties(1),
|
||||||
...staticAuxProperties(2),
|
...staticAuxProperties(2),
|
||||||
...staticAuxProperties(3),
|
...staticAuxProperties(3),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TemplateCompletion {
|
|
||||||
cursorIndex: number;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the partial string b needed to autocomplete string a
|
* Returns the partial string b needed to autocomplete string a
|
||||||
* @example matchRemaining('te', 'test') -> 'st'
|
* @example matchRemaining('te', 'test') -> 'st'
|
||||||
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
|
|
||||||
const textBeforeCursor = text.slice(0, cursorIndex);
|
|
||||||
const start = textBeforeCursor.lastIndexOf('{{');
|
|
||||||
if (start === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
|
|
||||||
if (closeBeforeCursor > start) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeAfterStart = text.indexOf('}}', start);
|
|
||||||
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
|
|
||||||
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
|
|
||||||
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
|
|
||||||
|
|
||||||
return {
|
|
||||||
end,
|
|
||||||
start,
|
|
||||||
template: text.slice(start, cursorIndex),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the last unclosed starting template before the cursor.
|
* Selects the last starting template in a string
|
||||||
*/
|
*/
|
||||||
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
|
export function selectFromLastTemplate(text: string) {
|
||||||
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
|
const lastBraceIndex = text.lastIndexOf('{{');
|
||||||
}
|
if (lastBraceIndex !== -1) {
|
||||||
|
return text.slice(lastBraceIndex);
|
||||||
/**
|
|
||||||
* Replaces the active template fragment before the cursor with the selected suggestion.
|
|
||||||
*/
|
|
||||||
export function completeTemplateAtCursor(
|
|
||||||
text: string,
|
|
||||||
suggestion: string,
|
|
||||||
cursorIndex = text.length,
|
|
||||||
): TemplateCompletion {
|
|
||||||
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
|
|
||||||
if (!activeTemplateRange) {
|
|
||||||
const value = text + matchRemaining(text, suggestion);
|
|
||||||
return { value, cursorIndex: value.length };
|
|
||||||
}
|
}
|
||||||
|
return '';
|
||||||
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
|
|
||||||
return {
|
|
||||||
value,
|
|
||||||
cursorIndex: activeTemplateRange.start + suggestion.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
|
||||||
import { useCallback, useMemo, useState, type RefObject } from 'react';
|
|
||||||
|
|
||||||
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
|
|
||||||
|
|
||||||
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
|
|
||||||
|
|
||||||
function getCursorIndex(element: TemplateElement | null, fallback: string) {
|
|
||||||
return element?.selectionStart ?? fallback.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
element?.focus();
|
|
||||||
element?.setSelectionRange(cursorIndex, cursorIndex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTemplateAutocomplete<T extends TemplateElement>(
|
|
||||||
value: string,
|
|
||||||
autocompleteList: string[],
|
|
||||||
elementRef: RefObject<T | null>,
|
|
||||||
onValueChange: (value: string) => void,
|
|
||||||
) {
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const [cursor, setCursor] = useState(value.length);
|
|
||||||
|
|
||||||
const activeTemplate = selectActiveTemplate(value, cursor);
|
|
||||||
const suggestions = useMemo(() => {
|
|
||||||
if (!activeTemplate) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
|
|
||||||
}, [activeTemplate, autocompleteList]);
|
|
||||||
|
|
||||||
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
|
|
||||||
setCursor(cursorIndex);
|
|
||||||
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateCursor = useCallback(() => {
|
|
||||||
const cursorIndex = getCursorIndex(elementRef.current, value);
|
|
||||||
setCursorForValue(value, cursorIndex);
|
|
||||||
}, [elementRef, setCursorForValue, value]);
|
|
||||||
|
|
||||||
const handleValueChange = useCallback(
|
|
||||||
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
|
|
||||||
if (eventDetails.reason === 'item-press') {
|
|
||||||
eventDetails.cancel();
|
|
||||||
const completed = completeTemplateAtCursor(value, nextValue, cursor);
|
|
||||||
setCursorForValue(completed.value, completed.cursorIndex);
|
|
||||||
onValueChange(completed.value);
|
|
||||||
focusCursor(elementRef.current, completed.cursorIndex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
|
|
||||||
setCursorForValue(nextValue, cursorIndex);
|
|
||||||
onValueChange(nextValue);
|
|
||||||
},
|
|
||||||
[cursor, elementRef, onValueChange, setCursorForValue, value],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleValueChange,
|
|
||||||
open: showSuggestions && suggestions.length > 0,
|
|
||||||
setCursorForValue,
|
|
||||||
setShowSuggestions,
|
|
||||||
suggestions,
|
|
||||||
updateCursor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+13
-36
@@ -1,6 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
ImportedFields,
|
|
||||||
RundownImportMode,
|
|
||||||
SpreadsheetPreviewResponse,
|
SpreadsheetPreviewResponse,
|
||||||
SpreadsheetWorksheetMetadata,
|
SpreadsheetWorksheetMetadata,
|
||||||
SpreadsheetWorksheetOptions,
|
SpreadsheetWorksheetOptions,
|
||||||
@@ -24,7 +22,7 @@ import Info from '../../../../../common/components/info/Info';
|
|||||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||||
import Modal from '../../../../../common/components/modal/Modal';
|
import Modal from '../../../../../common/components/modal/Modal';
|
||||||
import useRundown from '../../../../../common/hooks-query/useRundown';
|
import useRundown from '../../../../../common/hooks-query/useRundown';
|
||||||
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
import GSheetSetup from './GSheetSetup';
|
import GSheetSetup from './GSheetSetup';
|
||||||
import SheetImportEditor from './sheet-import/SheetImportEditor';
|
import SheetImportEditor from './sheet-import/SheetImportEditor';
|
||||||
@@ -37,7 +35,6 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
|
|||||||
type ActiveSource =
|
type ActiveSource =
|
||||||
| {
|
| {
|
||||||
kind: 'excel';
|
kind: 'excel';
|
||||||
fileName: string;
|
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
closedByUser: boolean;
|
closedByUser: boolean;
|
||||||
@@ -58,7 +55,7 @@ export default function SourcesPanel() {
|
|||||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||||
|
|
||||||
const { data: currentRundown } = useRundown();
|
const { data: currentRundown } = useRundown();
|
||||||
const { applyImport } = useSpreadsheetImport();
|
const { importRundown } = useSpreadsheetImport();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -77,7 +74,6 @@ export default function SourcesPanel() {
|
|||||||
const worksheetOptions = await uploadExcel(fileToUpload);
|
const worksheetOptions = await uploadExcel(fileToUpload);
|
||||||
setActiveSource({
|
setActiveSource({
|
||||||
kind: 'excel',
|
kind: 'excel',
|
||||||
fileName: fileToUpload.name,
|
|
||||||
worksheetNames: worksheetOptions.worksheets,
|
worksheetNames: worksheetOptions.worksheets,
|
||||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||||
closedByUser: false,
|
closedByUser: false,
|
||||||
@@ -130,32 +126,21 @@ export default function SourcesPanel() {
|
|||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyImport = async (
|
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
|
||||||
preview: SpreadsheetPreviewResponse,
|
|
||||||
mode: RundownImportMode,
|
|
||||||
newRundownTitle: string,
|
|
||||||
providedFields: ImportedFields,
|
|
||||||
) => {
|
|
||||||
if (mode === 'new') {
|
|
||||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
|
||||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
|
||||||
handleFinished();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentRundown) {
|
if (!currentRundown) {
|
||||||
throw new Error('No current rundown loaded');
|
throw new Error('No current rundown loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// override or merge into the current rundown; merge uses providedFields to know which columns
|
await importRundown(
|
||||||
// the sheet supplied so matched events keep the rest (e.g. automations)
|
{
|
||||||
await applyImport({
|
[currentRundown.id]: {
|
||||||
mode,
|
...preview.rundown,
|
||||||
targetRundownId: currentRundown.id,
|
id: currentRundown.id,
|
||||||
rundown: preview.rundown,
|
title: currentRundown.title,
|
||||||
customFields: preview.customFields,
|
},
|
||||||
providedFields,
|
},
|
||||||
});
|
preview.customFields,
|
||||||
|
);
|
||||||
handleFinished();
|
handleFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,13 +208,6 @@ export default function SourcesPanel() {
|
|||||||
if (activeSource.kind === 'excel') return 'excel';
|
if (activeSource.kind === 'excel') return 'excel';
|
||||||
return `gsheet:${activeSource.sheetId}`;
|
return `gsheet:${activeSource.sheetId}`;
|
||||||
})();
|
})();
|
||||||
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
|
|
||||||
// for Excel, or the document title for Google Sheets
|
|
||||||
const spreadsheetName = (() => {
|
|
||||||
if (!activeSource) return '';
|
|
||||||
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
|
|
||||||
return activeSource.title;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -309,7 +287,6 @@ export default function SourcesPanel() {
|
|||||||
bodyElements={
|
bodyElements={
|
||||||
<SheetImportEditor
|
<SheetImportEditor
|
||||||
sourceKey={sourceKey ?? 'spreadsheet'}
|
sourceKey={sourceKey ?? 'spreadsheet'}
|
||||||
defaultRundownName={spreadsheetName}
|
|
||||||
worksheetNames={activeSource?.worksheetNames ?? []}
|
worksheetNames={activeSource?.worksheetNames ?? []}
|
||||||
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
|
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
|
||||||
loadMetadata={loadWorksheetMetadata}
|
loadMetadata={loadWorksheetMetadata}
|
||||||
|
|||||||
-55
@@ -1,55 +0,0 @@
|
|||||||
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
|
|
||||||
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Button from '../../../../../../common/components/buttons/Button';
|
|
||||||
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
|
|
||||||
|
|
||||||
interface ApplyImportButtonProps {
|
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
|
||||||
mode: RundownImportMode;
|
|
||||||
disabled: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
onApply: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply action for the spreadsheet import.
|
|
||||||
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
|
|
||||||
* Requires a second click to confirm when applying would stop a running playback.
|
|
||||||
*/
|
|
||||||
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
|
|
||||||
const playback = usePlayback();
|
|
||||||
const loadedEventId = useSelectedEventId();
|
|
||||||
|
|
||||||
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
|
|
||||||
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
|
|
||||||
const willLoadedEventBeOverriden = !(
|
|
||||||
loadedEntry !== undefined &&
|
|
||||||
isOntimeEvent(loadedEntry) &&
|
|
||||||
isPlayableEvent(loadedEntry)
|
|
||||||
);
|
|
||||||
|
|
||||||
// applying stops playback when creating a new rundown, or when the playing event does not survive
|
|
||||||
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
|
|
||||||
|
|
||||||
// two-step confirmation before applying an import that stops playback
|
|
||||||
const [confirmStop, setConfirmStop] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
setConfirmStop(false);
|
|
||||||
}, [mode, preview]);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (willStopPlayback && !confirmStop) {
|
|
||||||
setConfirmStop(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onApply();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
|
|
||||||
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-5
@@ -33,11 +33,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.importModeTrigger {
|
|
||||||
min-width: 12rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addColumnTrigger {
|
.addColumnTrigger {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
+29
-116
@@ -1,20 +1,10 @@
|
|||||||
import type {
|
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||||
ImportedFields,
|
|
||||||
RundownImportMode,
|
|
||||||
SpreadsheetPreviewResponse,
|
|
||||||
SpreadsheetWorksheetMetadata,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import type { ImportMap } from 'ontime-utils';
|
import type { ImportMap } from 'ontime-utils';
|
||||||
import { useMemo } from 'react';
|
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
|
||||||
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import Button from '../../../../../../common/components/buttons/Button';
|
import Button from '../../../../../../common/components/buttons/Button';
|
||||||
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
|
|
||||||
import Input from '../../../../../../common/components/input/input/Input';
|
|
||||||
import Select from '../../../../../../common/components/select/Select';
|
import Select from '../../../../../../common/components/select/Select';
|
||||||
import * as Panel from '../../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../../panel-utils/PanelUtils';
|
||||||
import ApplyImportButton from './ApplyImportButton';
|
|
||||||
import { isIdColumnMapped } from './importMapUtils';
|
|
||||||
import PreviewTable from './preview/PreviewTable';
|
import PreviewTable from './preview/PreviewTable';
|
||||||
import SheetImportMappingPane from './SheetImportMappingPane';
|
import SheetImportMappingPane from './SheetImportMappingPane';
|
||||||
import { useSheetImportForm } from './useSheetImportForm';
|
import { useSheetImportForm } from './useSheetImportForm';
|
||||||
@@ -23,46 +13,17 @@ import style from './SheetImportEditor.module.scss';
|
|||||||
|
|
||||||
interface SheetImportEditorProps {
|
interface SheetImportEditorProps {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
defaultRundownName: string;
|
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
||||||
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
|
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
|
||||||
onApply: (
|
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
|
||||||
preview: SpreadsheetPreviewResponse,
|
|
||||||
mode: RundownImportMode,
|
|
||||||
newRundownTitle: string,
|
|
||||||
providedFields: ImportedFields,
|
|
||||||
) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onExport?: (importMap: ImportMap) => Promise<void>;
|
onExport?: (importMap: ImportMap) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const importModeOptions: Array<{
|
|
||||||
value: RundownImportMode;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: 'override',
|
|
||||||
label: 'Replace current rundown',
|
|
||||||
description: 'Spreadsheet data completely replaces current rundown',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'merge',
|
|
||||||
label: 'Merge with current rundown',
|
|
||||||
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'new',
|
|
||||||
label: 'New rundown',
|
|
||||||
description: 'Create a new rundown to import the data into. Loads the new rundown',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SheetImportEditor({
|
export default function SheetImportEditor({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
defaultRundownName,
|
|
||||||
worksheetNames,
|
worksheetNames,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
loadMetadata,
|
loadMetadata,
|
||||||
@@ -88,16 +49,11 @@ export default function SheetImportEditor({
|
|||||||
isBusy,
|
isBusy,
|
||||||
canPreview,
|
canPreview,
|
||||||
displayError,
|
displayError,
|
||||||
importMode,
|
|
||||||
setImportMode,
|
|
||||||
newRundownTitle,
|
|
||||||
setNewRundownTitle,
|
|
||||||
handlePreviewSubmit,
|
handlePreviewSubmit,
|
||||||
handleExportSubmit,
|
handleExportSubmit,
|
||||||
handleApply,
|
handleApply,
|
||||||
} = useSheetImportForm({
|
} = useSheetImportForm({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
defaultRundownName,
|
|
||||||
worksheetNames,
|
worksheetNames,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
loadMetadata,
|
loadMetadata,
|
||||||
@@ -106,19 +62,6 @@ export default function SheetImportEditor({
|
|||||||
onExport,
|
onExport,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
|
|
||||||
const importModeItems = useMemo<DropdownMenuOption[]>(
|
|
||||||
() =>
|
|
||||||
importModeOptions.map((option) => ({
|
|
||||||
type: 'item',
|
|
||||||
label: option.label,
|
|
||||||
description: option.description,
|
|
||||||
icon: importMode === option.value ? IoCheckmark : undefined,
|
|
||||||
onClick: () => setImportMode(option.value),
|
|
||||||
})),
|
|
||||||
[importMode, setImportMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
|
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
|
||||||
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
|
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
|
||||||
@@ -164,63 +107,33 @@ export default function SheetImportEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayError && <Panel.Error>{displayError}</Panel.Error>}
|
{displayError && <Panel.Error>{displayError}</Panel.Error>}
|
||||||
{importMode === 'merge' && !isIdColumnMapped(values) && (
|
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
|
||||||
<Panel.Description tone='warning'>
|
<Button onClick={onCancel} disabled={isBusy}>
|
||||||
<IoWarningOutline /> No ID column mapped — merge matches entries by ID, so it will behave like Replace. Export
|
Cancel
|
||||||
your rundown to a spreadsheet first to keep its IDs.
|
</Button>
|
||||||
</Panel.Description>
|
{onExport && (
|
||||||
)}
|
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
|
||||||
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
|
<IoArrowUpOutline />
|
||||||
<Panel.InlineElements wrap='wrap'>
|
Export
|
||||||
<label className={style.worksheetControl}>
|
|
||||||
<span className={style.worksheetLabel}>Import mode</span>
|
|
||||||
<DropdownMenu
|
|
||||||
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
|
|
||||||
items={importModeItems}
|
|
||||||
>
|
|
||||||
{selectedImportMode.label}
|
|
||||||
<IoChevronDown />
|
|
||||||
</DropdownMenu>
|
|
||||||
</label>
|
|
||||||
{importMode === 'new' && (
|
|
||||||
<label className={style.worksheetControl}>
|
|
||||||
<span className={style.worksheetLabel}>New rundown name</span>
|
|
||||||
<Input
|
|
||||||
value={newRundownTitle}
|
|
||||||
onChange={(event) => setNewRundownTitle(event.target.value)}
|
|
||||||
placeholder={state.preview?.rundown.title || 'Imported rundown'}
|
|
||||||
aria-label='New rundown name'
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</Panel.InlineElements>
|
|
||||||
<Panel.InlineElements wrap='wrap'>
|
|
||||||
<Button onClick={onCancel} disabled={isBusy}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
</Button>
|
||||||
{onExport && (
|
)}
|
||||||
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
|
<Button
|
||||||
<IoArrowUpOutline />
|
variant={state.preview ? undefined : 'primary'}
|
||||||
Export
|
onClick={handlePreviewSubmit}
|
||||||
</Button>
|
disabled={!canPreview}
|
||||||
)}
|
loading={state.loading === 'preview'}
|
||||||
<Button
|
>
|
||||||
variant={state.preview ? undefined : 'primary'}
|
<IoEye />
|
||||||
onClick={handlePreviewSubmit}
|
Preview import
|
||||||
disabled={!canPreview}
|
</Button>
|
||||||
loading={state.loading === 'preview'}
|
<Button
|
||||||
>
|
variant='primary'
|
||||||
<IoEye />
|
onClick={handleApply}
|
||||||
Preview import
|
disabled={!state.preview || isBusy}
|
||||||
</Button>
|
loading={state.loading === 'apply'}
|
||||||
<ApplyImportButton
|
>
|
||||||
preview={state.preview}
|
Apply import
|
||||||
mode={importMode}
|
</Button>
|
||||||
disabled={!state.preview || isBusy}
|
|
||||||
loading={state.loading === 'apply'}
|
|
||||||
onApply={handleApply}
|
|
||||||
/>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-68
@@ -1,21 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
builtInFieldDefs,
|
builtInFieldDefs,
|
||||||
convertToImportMap,
|
convertToImportMap,
|
||||||
createDefaultFormValues,
|
createDefaultFormValues,
|
||||||
defaultImportMode,
|
|
||||||
getImportWarnings,
|
getImportWarnings,
|
||||||
getPersistedImportMode,
|
|
||||||
getProvidedImportFields,
|
|
||||||
getResolvedCustomFields,
|
getResolvedCustomFields,
|
||||||
isIdColumnMapped,
|
|
||||||
persistImportMode,
|
|
||||||
} from '../importMapUtils';
|
} from '../importMapUtils';
|
||||||
|
|
||||||
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
|
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
|
||||||
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
|
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
|
||||||
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
|
|
||||||
|
|
||||||
describe('getImportWarnings()', () => {
|
describe('getImportWarnings()', () => {
|
||||||
it('warns when two mappings target the same spreadsheet column', () => {
|
it('warns when two mappings target the same spreadsheet column', () => {
|
||||||
@@ -134,64 +128,3 @@ describe('convertToImportMap()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getProvidedImportFields()', () => {
|
|
||||||
it('reports the mapped built-in and custom fields the sheet supplies', () => {
|
|
||||||
const values = createDefaultFormValues();
|
|
||||||
values.builtIn[titleIndex] = { header: 'title', enabled: true };
|
|
||||||
values.builtIn[cueIndex] = { header: '', enabled: false };
|
|
||||||
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
|
|
||||||
|
|
||||||
const provided = getProvidedImportFields(convertToImportMap(values));
|
|
||||||
|
|
||||||
// enabled built-in mappings are reported as event fields, disabled ones are not
|
|
||||||
expect(provided.event).toContain('title');
|
|
||||||
expect(provided.event).not.toContain('cue');
|
|
||||||
// the id column is only used for matching, never overwritten
|
|
||||||
expect(provided.event).not.toContain('id');
|
|
||||||
// custom fields are reported symmetrically by their resolved Ontime name
|
|
||||||
expect(provided.custom).toStrictEqual(['FOH Monitor']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isIdColumnMapped()', () => {
|
|
||||||
it('is true when the ID field is enabled with a header', () => {
|
|
||||||
// the default form maps the ID column
|
|
||||||
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when the ID field is disabled', () => {
|
|
||||||
const values = createDefaultFormValues();
|
|
||||||
values.builtIn[idIndex] = { header: 'id', enabled: false };
|
|
||||||
expect(isIdColumnMapped(values)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when the ID field header is blank', () => {
|
|
||||||
const values = createDefaultFormValues();
|
|
||||||
values.builtIn[idIndex] = { header: ' ', enabled: true };
|
|
||||||
expect(isIdColumnMapped(values)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('import mode persistence', () => {
|
|
||||||
const sourceKey = 'excel-test';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to override when nothing is persisted', () => {
|
|
||||||
expect(getPersistedImportMode(sourceKey)).toBe('override');
|
|
||||||
expect(defaultImportMode).toBe('override');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips a persisted value', () => {
|
|
||||||
persistImportMode(sourceKey, 'merge');
|
|
||||||
expect(getPersistedImportMode(sourceKey)).toBe('merge');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the default when the persisted value is invalid', () => {
|
|
||||||
persistImportMode(sourceKey, 'nonsense' as never);
|
|
||||||
expect(getPersistedImportMode(sourceKey)).toBe('override');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+3
-58
@@ -1,4 +1,3 @@
|
|||||||
import type { ImportedFields, RundownImportMode } from 'ontime-types';
|
|
||||||
import type { ImportMap } from 'ontime-utils';
|
import type { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||||
@@ -50,16 +49,6 @@ export function createDefaultFormValues(): ImportFormValues {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
|
|
||||||
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
|
|
||||||
*/
|
|
||||||
export function isIdColumnMapped(values: ImportFormValues): boolean {
|
|
||||||
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
|
|
||||||
const field = values.builtIn[idIndex];
|
|
||||||
return Boolean(field?.enabled && field.header.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitiseOntimeCustomFieldLabel(importName: string): string {
|
function sanitiseOntimeCustomFieldLabel(importName: string): string {
|
||||||
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
|
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
|
||||||
const sanitised = importName
|
const sanitised = importName
|
||||||
@@ -92,24 +81,6 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the fields the import map supplies — the complete description of what the incoming data
|
|
||||||
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
|
|
||||||
* onto a matched event and keep everything else (e.g. automations) untouched.
|
|
||||||
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
|
|
||||||
* for matching, not overwritten.
|
|
||||||
*/
|
|
||||||
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
|
|
||||||
const event: string[] = [];
|
|
||||||
for (const [key, value] of Object.entries(importMap)) {
|
|
||||||
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
|
|
||||||
if (typeof value === 'string' && value.trim() !== '') {
|
|
||||||
event.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { event, custom: Object.keys(importMap.custom) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertToImportMap(values: ImportFormValues): ImportMap {
|
export function convertToImportMap(values: ImportFormValues): ImportMap {
|
||||||
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
|
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
|
||||||
(accumulator, { ontimeName, importName }) => {
|
(accumulator, { ontimeName, importName }) => {
|
||||||
@@ -159,12 +130,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
|
|||||||
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
||||||
const storageKey = getImportMapKey(sourceKey);
|
const storageKey = getImportMapKey(sourceKey);
|
||||||
try {
|
try {
|
||||||
const persistedData = localStorage.getItem(storageKey);
|
const raw = localStorage.getItem(storageKey);
|
||||||
if (!persistedData) {
|
if (!raw) {
|
||||||
return { values: createDefaultFormValues(), isPersisted: false };
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(persistedData);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (isPersistedFormValues(parsed)) {
|
if (isPersistedFormValues(parsed)) {
|
||||||
return { values: parsed, isPersisted: true };
|
return { values: parsed, isPersisted: true };
|
||||||
}
|
}
|
||||||
@@ -179,32 +150,6 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The import mode (new / merge / override) is persisted separately from the field mapping
|
|
||||||
* so the mapping schema guard stays untouched.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Default import mode: replace matched elements in the current rundown */
|
|
||||||
export const defaultImportMode: RundownImportMode = 'override';
|
|
||||||
|
|
||||||
function getImportModeKey(sourceKey: string) {
|
|
||||||
return makeStageKey(`import-mode:${sourceKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persists the import mode for a given source */
|
|
||||||
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
|
|
||||||
localStorage.setItem(getImportModeKey(sourceKey), mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
|
|
||||||
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
|
|
||||||
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
|
|
||||||
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
|
|
||||||
return persisted;
|
|
||||||
}
|
|
||||||
return defaultImportMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
|
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+5
-34
@@ -1,12 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type {
|
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||||
ImportedFields,
|
|
||||||
RundownImportMode,
|
|
||||||
SpreadsheetPreviewResponse,
|
|
||||||
SpreadsheetWorksheetMetadata,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
||||||
@@ -16,11 +11,8 @@ import {
|
|||||||
builtInFieldDefs,
|
builtInFieldDefs,
|
||||||
convertToImportMap,
|
convertToImportMap,
|
||||||
getImportWarnings,
|
getImportWarnings,
|
||||||
getPersistedImportMode,
|
|
||||||
getPersistedImportState,
|
getPersistedImportState,
|
||||||
getProvidedImportFields,
|
|
||||||
getResolvedCustomFields,
|
getResolvedCustomFields,
|
||||||
persistImportMode,
|
|
||||||
persistImportState,
|
persistImportState,
|
||||||
} from './importMapUtils';
|
} from './importMapUtils';
|
||||||
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
|
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
|
||||||
@@ -108,23 +100,16 @@ function buildColumnLabels(values: ImportFormValues): string[] {
|
|||||||
|
|
||||||
interface UseSheetImportFormProps {
|
interface UseSheetImportFormProps {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
defaultRundownName: string;
|
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
||||||
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
|
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
|
||||||
onApply: (
|
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
|
||||||
preview: SpreadsheetPreviewResponse,
|
|
||||||
mode: RundownImportMode,
|
|
||||||
newRundownTitle: string,
|
|
||||||
providedFields: ImportedFields,
|
|
||||||
) => Promise<void>;
|
|
||||||
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
|
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSheetImportForm({
|
export function useSheetImportForm({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
defaultRundownName,
|
|
||||||
worksheetNames,
|
worksheetNames,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
loadMetadata,
|
loadMetadata,
|
||||||
@@ -185,8 +170,6 @@ export function useSheetImportForm({
|
|||||||
const columnLabels = buildColumnLabels(values);
|
const columnLabels = buildColumnLabels(values);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
||||||
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
|
|
||||||
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
|
|
||||||
const warnings = getImportWarnings(values, headers);
|
const warnings = getImportWarnings(values, headers);
|
||||||
const warningCount = Object.values(warnings).filter(Boolean).length;
|
const warningCount = Object.values(warnings).filter(Boolean).length;
|
||||||
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
||||||
@@ -199,12 +182,6 @@ export function useSheetImportForm({
|
|||||||
dispatch({ type: 'reset' });
|
dispatch({ type: 'reset' });
|
||||||
}, [initialFormValues, reset]);
|
}, [initialFormValues, reset]);
|
||||||
|
|
||||||
// Update import mode and rundown name if the source changes
|
|
||||||
useEffect(() => {
|
|
||||||
setImportMode(getPersistedImportMode(sourceKey));
|
|
||||||
setNewRundownTitle(defaultRundownName);
|
|
||||||
}, [sourceKey, defaultRundownName]);
|
|
||||||
|
|
||||||
// Keep the worksheet selection valid if the available worksheets change underneath the form.
|
// Keep the worksheet selection valid if the available worksheets change underneath the form.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worksheetNames.length === 0) return;
|
if (worksheetNames.length === 0) return;
|
||||||
@@ -264,15 +241,13 @@ export function useSheetImportForm({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
dispatch({ type: 'startApply' });
|
dispatch({ type: 'startApply' });
|
||||||
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
|
await onApply(state.preview);
|
||||||
await onApply(state.preview, importMode, newRundownTitle, providedFields);
|
|
||||||
persistImportState(sourceKey, getValues());
|
persistImportState(sourceKey, getValues());
|
||||||
persistImportMode(sourceKey, importMode);
|
|
||||||
dispatch({ type: 'applySuccess' });
|
dispatch({ type: 'applySuccess' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch({ type: 'failure', error: maybeAxiosError(error) });
|
dispatch({ type: 'failure', error: maybeAxiosError(error) });
|
||||||
}
|
}
|
||||||
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
|
}, [getValues, onApply, sourceKey, state.preview]);
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
async (formValues: ImportFormValues) => {
|
async (formValues: ImportFormValues) => {
|
||||||
@@ -325,10 +300,6 @@ export function useSheetImportForm({
|
|||||||
isBusy,
|
isBusy,
|
||||||
canPreview,
|
canPreview,
|
||||||
displayError,
|
displayError,
|
||||||
importMode,
|
|
||||||
setImportMode,
|
|
||||||
newRundownTitle,
|
|
||||||
setNewRundownTitle,
|
|
||||||
handlePreviewSubmit: handleSubmit(handlePreview),
|
handlePreviewSubmit: handleSubmit(handlePreview),
|
||||||
handleExportSubmit: handleSubmit(handleExport),
|
handleExportSubmit: handleSubmit(handleExport),
|
||||||
handleApply,
|
handleApply,
|
||||||
|
|||||||
+22
-9
@@ -1,17 +1,30 @@
|
|||||||
import { RundownImportPayload } from 'ontime-types';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { CustomFields, ProjectRundowns } from 'ontime-types';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { importRundownWithOptions } from '../../../../../common/api/rundown';
|
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
|
||||||
|
import { patchData } from '../../../../../common/api/db';
|
||||||
|
|
||||||
export default function useSpreadsheetImport() {
|
export default function useSpreadsheetImport() {
|
||||||
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
|
const queryClient = useQueryClient();
|
||||||
const applyImport = useCallback(async (payload: RundownImportPayload) => {
|
|
||||||
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
|
/** applies rundown and customFields to current project */
|
||||||
// through that single path rather than racing it with an optimistic write from here
|
const importRundown = useCallback(
|
||||||
await importRundownWithOptions(payload);
|
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
|
||||||
}, []);
|
await patchData({ rundowns, customFields });
|
||||||
|
// we are unable to optimistically set the rundown since we need
|
||||||
|
// it to be normalised
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: RUNDOWN,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: CUSTOM_FIELDS,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyImport,
|
importRundown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,6 @@
|
|||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagOvertime {
|
|
||||||
color: $playback-over;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
color: $section-white;
|
color: $section-white;
|
||||||
font-size: $text-body-size;
|
font-size: $text-body-size;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { dayInMs, millisToString } from 'ontime-utils';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||||
import useReport from '../../../../common/hooks-query/useReport';
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
import { useTimerProgress } from '../../../../common/hooks/useSocket';
|
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
|
|
||||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||||
|
|
||||||
import style from './PlaybackTimer.module.scss';
|
import style from './PlaybackTimer.module.scss';
|
||||||
@@ -25,14 +24,15 @@ function resolveAddedTimeLabel(addedTime: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||||
'use memo';
|
const timer = useTimer();
|
||||||
const timer = useTimerProgress();
|
|
||||||
|
|
||||||
const isRolling = timer.playback === Playback.Roll;
|
const isRolling = timer.playback === Playback.Roll;
|
||||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||||
const hasAddedTime = Boolean(timer.addedTime);
|
const hasAddedTime = Boolean(timer.addedTime);
|
||||||
|
|
||||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||||
|
|
||||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,13 +51,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
|||||||
{isWaiting ? (
|
{isWaiting ? (
|
||||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||||
) : (
|
) : (
|
||||||
<RunningStatus
|
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
|
||||||
startedAt={timer.startedAt}
|
|
||||||
expectedFinish={timer.expectedFinish}
|
|
||||||
isStopped={timer.playback === Playback.Stop}
|
|
||||||
isCountToEnd={timer.isCountToEnd}
|
|
||||||
isOvertime={isOvertime}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
@@ -68,18 +62,16 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
|||||||
interface RunningStatusProps {
|
interface RunningStatusProps {
|
||||||
startedAt: MaybeNumber;
|
startedAt: MaybeNumber;
|
||||||
expectedFinish: MaybeNumber;
|
expectedFinish: MaybeNumber;
|
||||||
isStopped: boolean;
|
playback: Playback;
|
||||||
isCountToEnd: boolean;
|
|
||||||
isOvertime: boolean;
|
|
||||||
}
|
}
|
||||||
|
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
|
||||||
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
|
if (playback === Playback.Stop) {
|
||||||
if (isStopped) {
|
|
||||||
return <StoppedStatus />;
|
return <StoppedStatus />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const started = millisToString(startedAt);
|
const started = millisToString(startedAt);
|
||||||
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
|
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||||
|
const finish = millisToString(finishedMs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -88,9 +80,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
|
|||||||
<span className={style.time}>{started}</span>
|
<span className={style.time}>{started}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={style.finish}>
|
<span className={style.finish}>
|
||||||
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
|
<span className={style.tag}>Expect end</span>
|
||||||
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
|
|
||||||
</span>
|
|
||||||
<span className={style.time}>{finish}</span>
|
<span className={style.time}>{finish}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -180,13 +180,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<OperatorGroup
|
<OperatorGroup key={entry.id} title={entry.title} />
|
||||||
key={entry.id}
|
|
||||||
title={entry.title}
|
|
||||||
colour={entry.colour}
|
|
||||||
count={entry.entries.length}
|
|
||||||
duration={entry.duration}
|
|
||||||
/>
|
|
||||||
{entry.entries.map((nestedEntryId) => {
|
{entry.entries.map((nestedEntryId) => {
|
||||||
const nestedEntry = rundown.entries[nestedEntryId];
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
if (!isOntimeEvent(nestedEntry)) {
|
if (!isOntimeEvent(nestedEntry)) {
|
||||||
@@ -223,7 +217,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
isLinkedToLoaded={isLinkedToLoaded}
|
isLinkedToLoaded={isLinkedToLoaded}
|
||||||
isSelected={isLoaded}
|
isSelected={isLoaded}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
groupColour={entry.colour}
|
|
||||||
selectedRef={isLoaded ? selectedRef : undefined}
|
selectedRef={isLoaded ? selectedRef : undefined}
|
||||||
showStart={showStart}
|
showStart={showStart}
|
||||||
subscribed={subscribedData}
|
subscribed={subscribedData}
|
||||||
|
|||||||
@@ -22,39 +22,17 @@
|
|||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.grouped {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 0.35rem;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
|
|
||||||
$viewer-card-bg-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
border-top: 1px solid $gray-1300;
|
border-top: 1px solid $gray-1300;
|
||||||
background-color: var(--operator-running-bg-override, $active-green);
|
background-color: var(--operator-running-bg-override, $active-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.grouped.running {
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
|
|
||||||
var(--operator-running-bg-override, $active-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.past {
|
&.past {
|
||||||
border-top: 1px solid transparent;
|
border-top: 1px solid transparent;
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupRail {
|
|
||||||
position: absolute;
|
|
||||||
inset-block: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 0.35rem;
|
|
||||||
background-color: var(--group-colour, $gray-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
.binder {
|
.binder {
|
||||||
grid-area: binder;
|
grid-area: binder;
|
||||||
color: $section-white;
|
color: $section-white;
|
||||||
@@ -96,9 +74,6 @@
|
|||||||
.plannedStart,
|
.plannedStart,
|
||||||
.timeUntil,
|
.timeUntil,
|
||||||
.runningTime {
|
.runningTime {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -120,21 +95,6 @@
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live {
|
|
||||||
color: $ui-black;
|
|
||||||
background-color: $ui-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.due {
|
|
||||||
color: $ui-black;
|
|
||||||
background-color: $orange-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.done {
|
|
||||||
color: $white-60;
|
|
||||||
background-color: $white-7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runningTime {
|
.runningTime {
|
||||||
grid-area: running;
|
grid-area: running;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ interface OperatorEventProps {
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
groupColour?: string;
|
|
||||||
selectedRef?: RefObject<HTMLDivElement | null>;
|
selectedRef?: RefObject<HTMLDivElement | null>;
|
||||||
showStart: boolean;
|
showStart: boolean;
|
||||||
subscribed: Subscribed;
|
subscribed: Subscribed;
|
||||||
@@ -47,7 +46,6 @@ function OperatorEvent({
|
|||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
isSelected,
|
isSelected,
|
||||||
isPast,
|
isPast,
|
||||||
groupColour,
|
|
||||||
selectedRef,
|
selectedRef,
|
||||||
showStart,
|
showStart,
|
||||||
subscribed,
|
subscribed,
|
||||||
@@ -70,12 +68,7 @@ function OperatorEvent({
|
|||||||
const mouseHandlers = useLongPress(handleLongPress);
|
const mouseHandlers = useLongPress(handleLongPress);
|
||||||
const cueColours = colour && getAccessibleColour(colour);
|
const cueColours = colour && getAccessibleColour(colour);
|
||||||
|
|
||||||
const operatorClasses = cx([
|
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
|
||||||
style.event,
|
|
||||||
groupColour && style.grouped,
|
|
||||||
isSelected && style.running,
|
|
||||||
isPast && style.past,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasFields = subscribed.some((field) => field.value);
|
const hasFields = subscribed.some((field) => field.value);
|
||||||
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
|
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
|
||||||
@@ -92,10 +85,8 @@ function OperatorEvent({
|
|||||||
data-testid={cue}
|
data-testid={cue}
|
||||||
ref={selectedRef}
|
ref={selectedRef}
|
||||||
onContextMenu={handleLongPress}
|
onContextMenu={handleLongPress}
|
||||||
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
|
|
||||||
{...mouseHandlers}
|
{...mouseHandlers}
|
||||||
>
|
>
|
||||||
{groupColour && <div className={style.groupRail} />}
|
|
||||||
<div className={style.binder} style={{ ...cueColours }}>
|
<div className={style.binder} style={{ ...cueColours }}>
|
||||||
<span className={style.cue}>{cue}</span>
|
<span className={style.cue}>{cue}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,11 +153,11 @@ function OperatorEventSchedule({
|
|||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
}: OperatorEventScheduleProps) {
|
}: OperatorEventScheduleProps) {
|
||||||
if (isPast) {
|
if (isPast) {
|
||||||
return <span className={cx([style.timeUntil, style.done])}>DONE</span>;
|
return <span className={style.timeUntil}>DONE</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>;
|
return <span className={style.timeUntil}>LIVE</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +186,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
|||||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'>
|
<span className={style.timeUntil} data-testid='time-until'>
|
||||||
{timeUntilString}
|
{timeUntilString}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,13 @@
|
|||||||
.group {
|
.group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
|
||||||
background-color: $gray-1350;
|
background-color: $gray-1350;
|
||||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
// tablet
|
||||||
flex: 1 1 auto;
|
@media (min-width: $min-tablet) {
|
||||||
min-width: 0;
|
.group {
|
||||||
overflow: hidden;
|
padding: 0.25rem 1rem;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: color-mix(in srgb, currentColor 60%, transparent);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import { CSSProperties, memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
|
||||||
|
|
||||||
import style from './OperatorGroup.module.scss';
|
import style from './OperatorGroup.module.scss';
|
||||||
|
|
||||||
interface OperatorGroup {
|
interface OperatorGroup {
|
||||||
title: string;
|
title: string;
|
||||||
colour: string;
|
|
||||||
count: number;
|
|
||||||
duration: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(OperatorGroup);
|
export default memo(OperatorGroup);
|
||||||
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
function OperatorGroup({ title }: OperatorGroup) {
|
||||||
const groupColour = colour || '#929292';
|
return <div className={style.group}>{title}</div>;
|
||||||
const groupColours = getAccessibleColour(groupColour);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
|
||||||
<span className={style.title}>{title}</span>
|
|
||||||
<span className={style.meta}>
|
|
||||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
|
||||||
<span>{formatDuration(duration)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,29 +105,12 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
handleCollapseGroup,
|
handleCollapseGroup,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
|
|
||||||
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
|
|
||||||
const jumpToCurrent = useCallback(() => {
|
|
||||||
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
|
|
||||||
if (!targetId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open parent group if the target is inside a collapsed group
|
|
||||||
const entry = entries[targetId];
|
|
||||||
if (entry && 'parent' in entry) {
|
|
||||||
expandGroup(entry.parent);
|
|
||||||
}
|
|
||||||
scrollToEntry(targetId);
|
|
||||||
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useRundownKeyboard({
|
useRundownKeyboard({
|
||||||
cursor,
|
cursor,
|
||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
setEntryCopyId,
|
setEntryCopyId,
|
||||||
jumpToCurrent,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DND handlers
|
// DND handlers
|
||||||
@@ -339,9 +322,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
increaseViewportBy={{ top: 200, bottom: 400 }}
|
increaseViewportBy={{ top: 200, bottom: 400 }}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
components={{
|
components={{
|
||||||
Header: isEditMode
|
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
|
||||||
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
|
|
||||||
: undefined,
|
|
||||||
Footer: () => (
|
Footer: () => (
|
||||||
<>
|
<>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
|
|||||||
@@ -3,94 +3,58 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem 1.5rem;
|
padding: 0.5rem;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcutSection {
|
.shortcutSection {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: min(100%, 48rem);
|
margin-top: 15vh;
|
||||||
margin-top: clamp(1.5rem, 8vh, 5rem);
|
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcuts {
|
.shortcuts {
|
||||||
display: grid;
|
font-size: calc(1rem - 3px);
|
||||||
gap: 0.875rem;
|
border-collapse: separate;
|
||||||
margin-top: 0.875rem;
|
border-spacing: 4rem 0;
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutGroup {
|
tr {
|
||||||
h3 {
|
white-space: nowrap;
|
||||||
margin: 0 0 0.375rem;
|
td:nth-child(odd) {
|
||||||
color: $ui-white;
|
text-align: left;
|
||||||
font-size: calc(1rem - 3px);
|
}
|
||||||
font-weight: 600;
|
td:nth-child(even) {
|
||||||
text-transform: uppercase;
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcutList {
|
.spacer {
|
||||||
display: grid;
|
height: 1rem;
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutRow {
|
|
||||||
min-height: 1.625rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: calc(1rem - 3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutLabel {
|
|
||||||
min-width: 0;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutKeys {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.25rem 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyCombo {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
color: $gray-500;
|
|
||||||
font-size: calc(1rem - 5px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt {
|
.prompt {
|
||||||
|
margin-left: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd {
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background-color: $gray-1200;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
border-radius: 2px;
|
||||||
|
font-weight: 400;
|
||||||
@media (max-width: 680px) {
|
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
|
||||||
.entryEditor {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutSection {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutRow {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutKeys {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { PropsWithChildren, memo } from 'react';
|
import { PropsWithChildren, memo } from 'react';
|
||||||
|
|
||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Kbd from '../../../common/components/kbd/Kbd';
|
|
||||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
|
|
||||||
import style from './EventEditorEmpty.module.scss';
|
import style from './EventEditorEmpty.module.scss';
|
||||||
@@ -13,127 +12,216 @@ function EventEditorEmpty() {
|
|||||||
<div className={style.entryEditor} data-testid='editor-container'>
|
<div className={style.entryEditor} data-testid='editor-container'>
|
||||||
<div className={style.shortcutSection}>
|
<div className={style.shortcutSection}>
|
||||||
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
|
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
|
||||||
<div className={style.shortcuts}>
|
<table className={style.shortcuts}>
|
||||||
<ShortcutGroup title='Search'>
|
<tbody>
|
||||||
<Shortcut label='Find in rundown'>
|
<tr>
|
||||||
<Combo keys={[deviceMod, 'F']} />
|
<td>Find in rundown</td>
|
||||||
</Shortcut>
|
<td>
|
||||||
<Shortcut label='Open settings'>
|
<Kbd>{deviceMod}</Kbd>
|
||||||
<Combo keys={[deviceMod, ',']} />
|
<AuxKey>+</AuxKey>
|
||||||
</Shortcut>
|
<Kbd>F</Kbd>
|
||||||
</ShortcutGroup>
|
</td>
|
||||||
|
</tr>
|
||||||
<ShortcutGroup title='Navigation'>
|
<tr>
|
||||||
<Shortcut label='Select entry'>
|
<td>Open Settings</td>
|
||||||
<Combo keys={[deviceAlt, '↑']} />
|
<td>
|
||||||
<Separator />
|
<Kbd>{deviceMod}</Kbd>
|
||||||
<Combo keys={[deviceAlt, '↓']} />
|
<AuxKey>+</AuxKey>
|
||||||
</Shortcut>
|
<Kbd>,</Kbd>
|
||||||
<Shortcut label='Select group'>
|
</td>
|
||||||
<Combo keys={[deviceAlt, 'Shift', '↑']} />
|
</tr>
|
||||||
<Separator />
|
<tr className={style.spacer} />
|
||||||
<Combo keys={[deviceAlt, 'Shift', '↓']} />
|
<tr>
|
||||||
</Shortcut>
|
<td>Select entry</td>
|
||||||
<Shortcut label='Jump to top / bottom'>
|
<td>
|
||||||
<Combo keys={['Home']} />
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
<Separator />
|
<AuxKey>+</AuxKey>
|
||||||
<Combo keys={['End']} />
|
<Kbd>↑</Kbd>
|
||||||
</Shortcut>
|
<AuxKey>/</AuxKey>
|
||||||
<Shortcut label='Page up / down'>
|
<Kbd>↓</Kbd>
|
||||||
<Combo keys={['PgUp']} />
|
</td>
|
||||||
<Separator />
|
</tr>
|
||||||
<Combo keys={['PgDn']} />
|
<tr>
|
||||||
</Shortcut>
|
<td>Select group</td>
|
||||||
<Shortcut label='Jump to current entry'>
|
<td>
|
||||||
<Combo keys={[deviceAlt, 'L']} />
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
</Shortcut>
|
<AuxKey>+</AuxKey>
|
||||||
<Shortcut label='Deselect entry'>
|
<Kbd>Shift</Kbd>
|
||||||
<Combo keys={['Esc']} />
|
<AuxKey>+</AuxKey>
|
||||||
</Shortcut>
|
<Kbd>↑</Kbd>
|
||||||
</ShortcutGroup>
|
<AuxKey>/</AuxKey>
|
||||||
|
<Kbd>↓</Kbd>
|
||||||
<ShortcutGroup title='Editing'>
|
</td>
|
||||||
<Shortcut label='Reorder selected entry'>
|
</tr>
|
||||||
<Combo keys={[deviceAlt, deviceMod, '↑']} />
|
<tr>
|
||||||
<Separator />
|
<td>Jump to top / bottom</td>
|
||||||
<Combo keys={[deviceAlt, deviceMod, '↓']} />
|
<td>
|
||||||
</Shortcut>
|
<Kbd>Home</Kbd>
|
||||||
<Shortcut label='Copy selected entry'>
|
<AuxKey>/</AuxKey>
|
||||||
<Combo keys={[deviceMod, 'C']} />
|
<Kbd>End</Kbd>
|
||||||
</Shortcut>
|
</td>
|
||||||
<Shortcut label='Cut selected entry'>
|
</tr>
|
||||||
<Combo keys={[deviceMod, 'X']} />
|
<tr>
|
||||||
</Shortcut>
|
<td>Page up / down</td>
|
||||||
<Shortcut label='Paste below'>
|
<td>
|
||||||
<Combo keys={[deviceMod, 'V']} />
|
<Kbd>PgUp</Kbd>
|
||||||
</Shortcut>
|
<AuxKey>/</AuxKey>
|
||||||
<Shortcut label='Paste above'>
|
<Kbd>PgDn</Kbd>
|
||||||
<Combo keys={[deviceMod, 'Shift', 'V']} />
|
</td>
|
||||||
</Shortcut>
|
</tr>
|
||||||
<Shortcut label='Clone selected entry'>
|
<tr>
|
||||||
<Combo keys={[deviceMod, 'D']} />
|
<td>Deselect entry</td>
|
||||||
</Shortcut>
|
<td>
|
||||||
<Shortcut label='Delete selected entry'>
|
<Kbd>Esc</Kbd>
|
||||||
<Combo keys={[deviceAlt, 'Backspace']} />
|
</td>
|
||||||
</Shortcut>
|
</tr>
|
||||||
</ShortcutGroup>
|
<tr className={style.spacer} />
|
||||||
|
<tr>
|
||||||
<ShortcutGroup title='Insert'>
|
<td>Reorder selected entry</td>
|
||||||
<Shortcut label='Add event below / above'>
|
<td>
|
||||||
<Combo keys={[deviceAlt, 'E']} />
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
<Separator />
|
<AuxKey>+</AuxKey>
|
||||||
<Combo keys={[deviceAlt, 'Shift', 'E']} />
|
<Kbd>{deviceMod}</Kbd>
|
||||||
</Shortcut>
|
<AuxKey>+</AuxKey>
|
||||||
<Shortcut label='Add group below / above'>
|
<Kbd>↑</Kbd>
|
||||||
<Combo keys={[deviceAlt, 'G']} />
|
<AuxKey>/</AuxKey>
|
||||||
<Separator />
|
<Kbd>↓</Kbd>
|
||||||
<Combo keys={[deviceAlt, 'Shift', 'G']} />
|
</td>
|
||||||
</Shortcut>
|
</tr>
|
||||||
<Shortcut label='Add milestone below / above'>
|
<tr>
|
||||||
<Combo keys={[deviceAlt, 'M']} />
|
<td>Copy selected entry</td>
|
||||||
<Separator />
|
<td>
|
||||||
<Combo keys={[deviceAlt, 'Shift', 'M']} />
|
<Kbd>{deviceMod}</Kbd>
|
||||||
</Shortcut>
|
<AuxKey>+</AuxKey>
|
||||||
<Shortcut label='Add delay below / above'>
|
<Kbd>C</Kbd>
|
||||||
<Combo keys={[deviceAlt, 'D']} />
|
</td>
|
||||||
<Separator />
|
</tr>
|
||||||
<Combo keys={[deviceAlt, 'Shift', 'D']} />
|
<tr>
|
||||||
</Shortcut>
|
<td>Cut selected entry</td>
|
||||||
</ShortcutGroup>
|
<td>
|
||||||
</div>
|
<Kbd>{deviceMod}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>X</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Paste above</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceMod}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>V</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Paste below</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceMod}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>V</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Clone selected entry</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceMod}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>D</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Delete selected entry</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>Backspace</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className={style.spacer} />
|
||||||
|
<tr>
|
||||||
|
<td>Add event below</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>E</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add event above</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>E</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add group below</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>G</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add group above</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>G</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add milestone below</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>M</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add milestone above</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>M</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add delay below</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>D</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Add delay above</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceAlt}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>D</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
|
function AuxKey({ children }: PropsWithChildren) {
|
||||||
return (
|
return <span className={style.divider}>{children}</span>;
|
||||||
<section className={style.shortcutGroup}>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<div className={style.shortcutList}>{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
|
function Kbd({ children }: PropsWithChildren) {
|
||||||
return (
|
return <span className={style.kbd}>{children}</span>;
|
||||||
<div className={style.shortcutRow}>
|
|
||||||
<span className={style.shortcutLabel}>{label}</span>
|
|
||||||
<span className={style.shortcutKeys}>{children}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Combo({ keys }: { keys: string[] }) {
|
|
||||||
return (
|
|
||||||
<span className={style.keyCombo}>
|
|
||||||
{keys.map((key) => (
|
|
||||||
<Kbd key={key}>{key}</Kbd>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Separator() {
|
|
||||||
return <span className={style.separator}>/</span>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
|
|||||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
|
import { formatTime } from '../../../../common/utils/time';
|
||||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||||
|
|
||||||
import style from '../EntryEditor.module.scss';
|
import style from '../EntryEditor.module.scss';
|
||||||
@@ -68,10 +68,8 @@ function EventEditorTimes({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasDelay = delay !== 0;
|
const hasDelay = delay !== 0;
|
||||||
const delayedStart = normaliseWallClock(timeStart + delay);
|
|
||||||
const delayedEnd = normaliseWallClock(timeEnd + delay);
|
|
||||||
const delayLabel = hasDelay
|
const delayLabel = hasDelay
|
||||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)} → ${formatTime(delayedEnd)}`
|
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)} → ${formatTime(timeEnd + delay)}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,6 +86,7 @@ function EventEditorTimes({
|
|||||||
timeStrategy={timeStrategy}
|
timeStrategy={timeStrategy}
|
||||||
linkStart={linkStart}
|
linkStart={linkStart}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
|
countToEnd={countToEnd}
|
||||||
showLabels
|
showLabels
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-9
@@ -11,17 +11,13 @@ import style from './QuickAddButtons.module.scss';
|
|||||||
|
|
||||||
interface QuickAddButtonsProps {
|
interface QuickAddButtonsProps {
|
||||||
previousEventId: MaybeString;
|
previousEventId: MaybeString;
|
||||||
nextEventId?: MaybeString;
|
|
||||||
parentGroup: MaybeString;
|
parentGroup: MaybeString;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(QuickAddButtons);
|
export default memo(QuickAddButtons);
|
||||||
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
|
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
|
||||||
const { addEntry } = useEntryActionsContext();
|
const { addEntry } = useEntryActionsContext();
|
||||||
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
|
|
||||||
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
|
|
||||||
const insertionOptions = appendOptions ?? prependOptions;
|
|
||||||
|
|
||||||
const addEvent = () => {
|
const addEvent = () => {
|
||||||
addEntry(
|
addEntry(
|
||||||
@@ -29,23 +25,44 @@ function QuickAddButtons({ previousEventId, nextEventId, parentGroup, background
|
|||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
parent: parentGroup,
|
parent: parentGroup,
|
||||||
},
|
},
|
||||||
insertionOptions,
|
{
|
||||||
|
after: previousEventId,
|
||||||
|
lastEventId: previousEventId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDelay = () => {
|
const addDelay = () => {
|
||||||
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
|
addEntry(
|
||||||
|
{ type: SupportedEntry.Delay, parent: parentGroup },
|
||||||
|
{
|
||||||
|
lastEventId: previousEventId,
|
||||||
|
after: previousEventId,
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMilestone = () => {
|
const addMilestone = () => {
|
||||||
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
|
addEntry(
|
||||||
|
{ type: SupportedEntry.Milestone, parent: parentGroup },
|
||||||
|
{
|
||||||
|
lastEventId: previousEventId,
|
||||||
|
after: previousEventId,
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addGroup = () => {
|
const addGroup = () => {
|
||||||
if (parentGroup !== null) {
|
if (parentGroup !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addEntry({ type: SupportedEntry.Group }, insertionOptions);
|
addEntry(
|
||||||
|
{ type: SupportedEntry.Group },
|
||||||
|
{
|
||||||
|
lastEventId: previousEventId,
|
||||||
|
after: previousEventId,
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function useRundownCommands({
|
|||||||
addEntry(patch, {
|
addEntry(patch, {
|
||||||
after: id && !above ? id : undefined,
|
after: id && !above ? id : undefined,
|
||||||
before: id && above ? id : undefined,
|
before: id && above ? id : undefined,
|
||||||
lastEventId: id && !above ? id : undefined,
|
lastEventId: !above && id ? id : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[addEntry],
|
[addEntry],
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ interface UseRundownKeyboardOptions {
|
|||||||
};
|
};
|
||||||
clearSelectedEvents: () => void;
|
clearSelectedEvents: () => void;
|
||||||
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
||||||
jumpToCurrent: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +40,6 @@ export function useRundownKeyboard({
|
|||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
setEntryCopyId,
|
setEntryCopyId,
|
||||||
jumpToCurrent,
|
|
||||||
}: UseRundownKeyboardOptions) {
|
}: UseRundownKeyboardOptions) {
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
|
|
||||||
@@ -129,8 +127,6 @@ export function useRundownKeyboard({
|
|||||||
{ preventDefault: true, usePhysicalKeys: true },
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
|
|
||||||
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
|
|
||||||
|
|
||||||
[
|
[
|
||||||
'alt + mod + ArrowDown',
|
'alt + mod + ArrowDown',
|
||||||
() => commands.moveEntry(cursor, 'down'),
|
() => commands.moveEntry(cursor, 'down'),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ $skip-opacity: 0.2;
|
|||||||
background-color: $block-bg;
|
background-color: $block-bg;
|
||||||
margin-block: 0.25rem;
|
margin-block: 0.25rem;
|
||||||
overflow: initial;
|
overflow: initial;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
@@ -17,7 +16,7 @@ $skip-opacity: 0.2;
|
|||||||
'binder pb-actions estatus estatus'
|
'binder pb-actions estatus estatus'
|
||||||
'binder ... ... ...';
|
'binder ... ... ...';
|
||||||
|
|
||||||
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
|
grid-template-columns: $block-binder-width 3rem 1fr 3rem;
|
||||||
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
|
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: $block-clearance;
|
padding-right: $block-clearance;
|
||||||
@@ -137,7 +136,6 @@ $skip-opacity: 0.2;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $block-clearance;
|
gap: $block-clearance;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventTimers.editMode:hover {
|
.eventTimers.editMode:hover {
|
||||||
@@ -157,7 +155,7 @@ $skip-opacity: 0.2;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
.warningMeta {
|
.nextTag {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $orange-500;
|
color: $orange-500;
|
||||||
letter-spacing: 0.03px;
|
letter-spacing: 0.03px;
|
||||||
@@ -233,10 +231,6 @@ $skip-opacity: 0.2;
|
|||||||
color: var(--status-color-active-override, $active-indicator);
|
color: var(--status-color-active-override, $active-indicator);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusIcon.countToEndStatus {
|
|
||||||
color: $orange-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusIcon.warning {
|
.statusIcon.warning {
|
||||||
color: $orange-500;
|
color: $orange-500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
|||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
@@ -156,7 +156,7 @@ export default function RundownEvent({
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
icon: IoTrash,
|
icon: IoTrash,
|
||||||
shortcut: `${deviceAlt}+Backspace`,
|
shortcut: `${deviceMod}+Del`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
clearSelectedEvents();
|
clearSelectedEvents();
|
||||||
deleteEntry(Array.from(selectedEvents));
|
deleteEntry(Array.from(selectedEvents));
|
||||||
@@ -202,7 +202,7 @@ export default function RundownEvent({
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
icon: IoTrash,
|
icon: IoTrash,
|
||||||
shortcut: `${deviceAlt}+Backspace`,
|
shortcut: `${deviceMod}+Del`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
deleteEntry([eventId]);
|
deleteEntry([eventId]);
|
||||||
unselect(eventId);
|
unselect(eventId);
|
||||||
|
|||||||
@@ -115,12 +115,12 @@ function RundownEventInner({
|
|||||||
delay={delay}
|
delay={delay}
|
||||||
timeStrategy={timeStrategy}
|
timeStrategy={timeStrategy}
|
||||||
linkStart={linkStart}
|
linkStart={linkStart}
|
||||||
|
countToEnd={countToEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||||
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
|
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
||||||
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<EventBlockPlayback
|
<EventBlockPlayback
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
@@ -157,13 +157,7 @@ function RundownEventInner({
|
|||||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
|
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
|
||||||
<LuArrowDownToLine
|
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
|
||||||
className={cx([
|
|
||||||
style.statusIcon,
|
|
||||||
countToEnd ? style.active : style.disabled,
|
|
||||||
countToEnd && style.countToEndStatus,
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text={automationTooltip} render={<span />}>
|
<Tooltip text={automationTooltip} render={<span />}>
|
||||||
<IoFlash className={automationIconClasses} />
|
<IoFlash className={automationIconClasses} />
|
||||||
|
|||||||
+2
-25
@@ -1,33 +1,10 @@
|
|||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { formatDelay } from '../rundownEvent.utils';
|
import { formatDelay } from '../rundownEvent.utils';
|
||||||
|
|
||||||
describe('formatDelay()', () => {
|
describe('formatDelay()', () => {
|
||||||
it('adds a given delay to the start time', () => {
|
it('adds a given delay to the start time', () => {
|
||||||
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
|
const timeStart = 60000; // 1 min
|
||||||
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
|
const delay = 60000; // 1 min
|
||||||
const result = formatDelay(timeStart, delay);
|
const result = formatDelay(timeStart, delay);
|
||||||
expect(result).toEqual('New start 00:02');
|
expect(result).toEqual('New start 00:02');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps negative delayed starts under midnight', () => {
|
|
||||||
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
|
|
||||||
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
|
|
||||||
const result = formatDelay(timeStart, delay);
|
|
||||||
expect(result).toEqual('New start 23:59');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps later-day negative delays using delay as the source of truth', () => {
|
|
||||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
|
|
||||||
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
|
|
||||||
const result = formatDelay(timeStart, delay);
|
|
||||||
expect(result).toEqual('New start 23:30');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays positive delays as wall-clock time', () => {
|
|
||||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
|
|
||||||
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
|
|
||||||
const result = formatDelay(timeStart, delay);
|
|
||||||
expect(result).toEqual('New start 02:30');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+2
@@ -3,5 +3,7 @@
|
|||||||
width: 0;
|
width: 0;
|
||||||
border-radius: 1px 0 0 1px;
|
border-radius: 1px 0 0 1px;
|
||||||
|
|
||||||
|
transition: 1s linear;
|
||||||
|
transition-property: width;
|
||||||
background-color: $gray-200;
|
background-color: $gray-200;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
|
|
||||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||||
|
import { getProgress } from '../../../../common/utils/getProgress';
|
||||||
|
|
||||||
import style from './RundownEventProgressBar.module.scss';
|
import style from './RundownEventProgressBar.module.scss';
|
||||||
|
|
||||||
export default function RundownEventProgressBar() {
|
export default function RundownEventProgressBar() {
|
||||||
const timer = useTimer();
|
const timer = useTimer();
|
||||||
|
|
||||||
const progress = useAnimatedProgress(timer.current, timer.duration);
|
const progress = getProgress(timer.current, timer.duration);
|
||||||
|
|
||||||
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
|
|
||||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||||
if (!delay) return;
|
if (!delay) return;
|
||||||
|
|
||||||
const delayedStart = normaliseWallClock(timeStart + delay);
|
const delayedStart = Math.max(0, timeStart + delay);
|
||||||
|
|
||||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||||
return `New start ${timeTag}`;
|
return `New start ${timeTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatGap(gap: number, isNextDay: boolean) {
|
export function formatGap(gap: number, isNextDay: boolean) {
|
||||||
if (gap === 0) {
|
if (gap === 0) {
|
||||||
if (isNextDay) {
|
if (isNextDay) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import Tag from '../../../common/components/tag/Tag';
|
|||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||||
@@ -66,7 +66,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete Group',
|
label: 'Delete Group',
|
||||||
icon: IoTrash,
|
icon: IoTrash,
|
||||||
shortcut: `${deviceAlt}+Backspace`,
|
shortcut: `${deviceMod}+Del`,
|
||||||
onClick: () => deleteEntry([data.id]),
|
onClick: () => deleteEntry([data.id]),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
|
|||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceAlt } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
icon: IoTrash,
|
icon: IoTrash,
|
||||||
shortcut: `${deviceAlt}+Backspace`,
|
shortcut: `${deviceMod}+Del`,
|
||||||
onClick: () => deleteEntry([entryId]),
|
onClick: () => deleteEntry([entryId]),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,15 +4,10 @@
|
|||||||
|
|
||||||
.timerNote {
|
.timerNote {
|
||||||
width: 1.25em;
|
width: 1.25em;
|
||||||
flex: 0 0 1.25em;
|
|
||||||
color: $blue-500;
|
color: $blue-500;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timerNotePlaceholder {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inactive {
|
.inactive {
|
||||||
color: $muted-gray;
|
color: $muted-gray;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import style from './TimeInputFlow.module.scss';
|
|||||||
|
|
||||||
interface TimeInputFlowProps {
|
interface TimeInputFlowProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
countToEnd: boolean;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -21,12 +22,12 @@ interface TimeInputFlowProps {
|
|||||||
linkStart: boolean;
|
linkStart: boolean;
|
||||||
delay: number;
|
delay: number;
|
||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
showWarnings?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(TimeInputFlow);
|
export default memo(TimeInputFlow);
|
||||||
function TimeInputFlow({
|
function TimeInputFlow({
|
||||||
eventId,
|
eventId,
|
||||||
|
countToEnd,
|
||||||
timeStart,
|
timeStart,
|
||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
@@ -34,7 +35,6 @@ function TimeInputFlow({
|
|||||||
linkStart,
|
linkStart,
|
||||||
delay,
|
delay,
|
||||||
showLabels,
|
showLabels,
|
||||||
showWarnings = true,
|
|
||||||
}: TimeInputFlowProps) {
|
}: TimeInputFlowProps) {
|
||||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||||
|
|
||||||
@@ -56,6 +56,10 @@ function TimeInputFlow({
|
|||||||
warnings.push('Over midnight');
|
warnings.push('Over midnight');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (countToEnd) {
|
||||||
|
warnings.push('Count to End');
|
||||||
|
}
|
||||||
|
|
||||||
const hasDelay = delay !== 0;
|
const hasDelay = delay !== 0;
|
||||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||||
@@ -132,19 +136,11 @@ function TimeInputFlow({
|
|||||||
</TimeInputGroup>
|
</TimeInputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showWarnings &&
|
{warnings.length > 0 && (
|
||||||
(warnings.length > 0 ? (
|
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
|
||||||
<Tooltip
|
<IoAlertCircleOutline />
|
||||||
text={warnings.join(' - ')}
|
</Tooltip>
|
||||||
className={style.timerNote}
|
)}
|
||||||
data-testid='event-warning'
|
|
||||||
render={<span />}
|
|
||||||
>
|
|
||||||
<IoAlertCircleOutline />
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
|
|||||||
//TODO: consider relative mode
|
//TODO: consider relative mode
|
||||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||||
const { event, showExpected } = props;
|
const { event, showExpected } = props;
|
||||||
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
|
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||||
|
|
||||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
|||||||
|
|
||||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||||
const plannedEnd = plannedStart + duration + delay;
|
const plannedEnd = plannedStart + duration + delay;
|
||||||
|
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
|
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
|
import { getExpectedStart } from 'ontime-utils';
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import useReport from '../../common/hooks-query/useReport';
|
import useReport from '../../common/hooks-query/useReport';
|
||||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||||
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
import { useCountdownOptions } from './countdown.options';
|
import { useCountdownOptions } from './countdown.options';
|
||||||
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||||
import { ScheduleTime } from './CountdownSubscriptions';
|
import { ScheduleTime } from './CountdownSubscriptions';
|
||||||
|
|
||||||
import './SingleEventCountdown.scss';
|
import './SingleEventCountdown.scss';
|
||||||
@@ -36,10 +38,8 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
|
|
||||||
|
|
||||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
|
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionStatusProps {
|
interface SubscriptionStatusProps {
|
||||||
event: CountdownEvent;
|
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
|||||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
|
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||||
@@ -271,7 +271,6 @@ export function extendEventData(
|
|||||||
offset,
|
offset,
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
|
|
||||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||||
return { ...event, expectedStart, endedAt, expectedEnd };
|
return { ...event, expectedStart, endedAt };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
|
|||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Planning',
|
label: 'Planning',
|
||||||
description: 'Edit focused list with planning stats',
|
description: 'Edit-focused list with planning stats',
|
||||||
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
||||||
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,51 +54,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterHint {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.em {
|
.em {
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
margin-inline: 0.25rem;
|
margin-inline: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hints {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem 1rem;
|
|
||||||
color: $label-gray;
|
|
||||||
font-size: calc(1rem - 3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hintItem {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollContainer {
|
.scrollContainer {
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.footer {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterHint {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { SupportedEntry } from 'ontime-types';
|
|||||||
import { KeyboardEvent, useState } from 'react';
|
import { KeyboardEvent, useState } from 'react';
|
||||||
|
|
||||||
import Input from '../../../common/components/input/input/Input';
|
import Input from '../../../common/components/input/input/Input';
|
||||||
import Kbd from '../../../common/components/kbd/Kbd';
|
|
||||||
import Modal from '../../../common/components/modal/Modal';
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
import useFinder from './useFinder';
|
import useFinder from './useFinder';
|
||||||
|
|
||||||
@@ -97,25 +96,8 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
|||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
<div className={style.footer}>
|
<div className={style.footer}>
|
||||||
<div className={style.hints}>
|
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
|
||||||
<span className={style.hintItem}>
|
<span className={style.em}>title</span> to filter search.
|
||||||
<Kbd>↑</Kbd>
|
|
||||||
<Kbd>↓</Kbd>
|
|
||||||
Navigate
|
|
||||||
</span>
|
|
||||||
<span className={style.hintItem}>
|
|
||||||
<Kbd>Enter</Kbd>
|
|
||||||
Go
|
|
||||||
</span>
|
|
||||||
<span className={style.hintItem}>
|
|
||||||
<Kbd>Esc</Kbd>
|
|
||||||
Close
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={style.filterHint}>
|
|
||||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
|
||||||
<span className={style.em}>title</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@
|
|||||||
|
|
||||||
.timer-container {
|
.timer-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
align-content: center;
|
||||||
flex-direction: column;
|
justify-self: center;
|
||||||
justify-content: center;
|
align-self: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Day } from 'ontime-types';
|
import { Day } from 'ontime-types';
|
||||||
import { CSSProperties, RefObject } from 'react';
|
import { CSSProperties, RefObject } from 'react';
|
||||||
|
|
||||||
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
|
|
||||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||||
|
import { getProgress } from '../../common/utils/getProgress';
|
||||||
import { alpha, cx } from '../../common/utils/styleUtils';
|
import { alpha, cx } from '../../common/utils/styleUtils';
|
||||||
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
|
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
@@ -169,7 +169,7 @@ function TimelineEntryStatus({
|
|||||||
/** Generates a block level progress bar */
|
/** Generates a block level progress bar */
|
||||||
function ActiveBlock() {
|
function ActiveBlock() {
|
||||||
const { current, duration } = useTimer();
|
const { current, duration } = useTimer();
|
||||||
const progress = useAnimatedProgress(current, duration);
|
const progress = getProgress(current, duration);
|
||||||
return (
|
return (
|
||||||
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
|
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,9 +82,9 @@
|
|||||||
|
|
||||||
.timer-container {
|
.timer-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
align-content: center;
|
||||||
flex-direction: column;
|
justify-self: center;
|
||||||
justify-content: center;
|
align-self: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.11.0",
|
"version": "4.10.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -125,13 +125,6 @@
|
|||||||
"**/*",
|
"**/*",
|
||||||
"*{.ts}"
|
"*{.ts}"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "../server/src/html/",
|
|
||||||
"to": "extraResources/html/",
|
|
||||||
"filter": [
|
|
||||||
"**/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.11.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||||
"extends": ["../../.oxlintrc.json"],
|
"extends": ["../../.oxlintrc.json"],
|
||||||
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"]
|
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.11.0",
|
"version": "4.10.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
"osc-min": "2.1.2",
|
"osc-min": "2.1.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
|
|||||||
@@ -163,70 +163,5 @@ describe('parseOutput', () => {
|
|||||||
secondarySource: 'secondary',
|
secondarySource: 'secondary',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses message-secondary with a text value', () => {
|
|
||||||
expect(
|
|
||||||
parseOutput({
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
text: 'hello',
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
|
||||||
text: 'hello',
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
parseOutput({
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
secondarySource: undefined,
|
|
||||||
text: 'hello',
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
|
||||||
text: 'hello',
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
parseOutput({
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
text: 'hello',
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
text: 'hello',
|
|
||||||
});
|
|
||||||
// an empty text is treated as no change
|
|
||||||
expect(
|
|
||||||
parseOutput({
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
text: '',
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
text: undefined,
|
|
||||||
});
|
|
||||||
// text can be set while clearing the secondary source
|
|
||||||
expect(
|
|
||||||
parseOutput({
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
secondarySource: null,
|
|
||||||
text: 'hello',
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
|
||||||
secondarySource: null,
|
|
||||||
text: 'hello',
|
|
||||||
});
|
|
||||||
expect(() =>
|
|
||||||
parseOutput({
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
text: 123,
|
|
||||||
}),
|
|
||||||
).toThrow('Unexpected payload type:');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { runtimeStorePlaceholder } from 'ontime-types';
|
|
||||||
|
|
||||||
import * as messageService from '../../../services/message-service/message.service.js';
|
|
||||||
import { toOntimeAction } from '../clients/ontime.client.js';
|
|
||||||
|
|
||||||
vi.mock('../../../services/message-service/message.service.js', () => ({
|
|
||||||
patch: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('toOntimeAction()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses templates in primary message text', () => {
|
|
||||||
toOntimeAction(
|
|
||||||
{
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-set',
|
|
||||||
text: 'Current: {{timer.current}}',
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...runtimeStorePlaceholder,
|
|
||||||
timer: {
|
|
||||||
...runtimeStorePlaceholder.timer,
|
|
||||||
current: 42,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(messageService.patch).toHaveBeenCalledWith({
|
|
||||||
timer: {
|
|
||||||
text: 'Current: 42',
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses templates in secondary message text', () => {
|
|
||||||
toOntimeAction(
|
|
||||||
{
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
text: 'Next: {{eventNext.title}}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...runtimeStorePlaceholder,
|
|
||||||
eventNext: {
|
|
||||||
id: 'next-event',
|
|
||||||
type: 'event',
|
|
||||||
cue: '2',
|
|
||||||
title: 'Keynote',
|
|
||||||
note: '',
|
|
||||||
timeStart: 0,
|
|
||||||
timeEnd: 0,
|
|
||||||
duration: 0,
|
|
||||||
timerType: 'count-down',
|
|
||||||
colour: '',
|
|
||||||
delay: 0,
|
|
||||||
isPublic: true,
|
|
||||||
skip: false,
|
|
||||||
endAction: 'none',
|
|
||||||
revision: 0,
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(messageService.patch).toHaveBeenCalledWith({
|
|
||||||
timer: {
|
|
||||||
secondarySource: 'secondary',
|
|
||||||
},
|
|
||||||
secondary: 'Next: Keynote',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can set secondary message text without changing the secondary source', () => {
|
|
||||||
toOntimeAction(
|
|
||||||
{
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
text: 'Next: {{eventNext.title}}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...runtimeStorePlaceholder,
|
|
||||||
eventNext: {
|
|
||||||
id: 'next-event',
|
|
||||||
type: 'event',
|
|
||||||
cue: '2',
|
|
||||||
title: 'Keynote',
|
|
||||||
note: '',
|
|
||||||
timeStart: 0,
|
|
||||||
timeEnd: 0,
|
|
||||||
duration: 0,
|
|
||||||
timerType: 'count-down',
|
|
||||||
colour: '',
|
|
||||||
delay: 0,
|
|
||||||
isPublic: true,
|
|
||||||
skip: false,
|
|
||||||
endAction: 'none',
|
|
||||||
revision: 0,
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(messageService.patch).toHaveBeenCalledWith({
|
|
||||||
secondary: 'Next: Keynote',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
|
|||||||
} else if (isHTTPOutput(payload)) {
|
} else if (isHTTPOutput(payload)) {
|
||||||
emitHTTP(payload, store);
|
emitHTTP(payload, store);
|
||||||
} else if (isOntimeAction(payload)) {
|
} else if (isOntimeAction(payload)) {
|
||||||
toOntimeAction(payload, store);
|
toOntimeAction(payload);
|
||||||
} else {
|
} else {
|
||||||
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,28 +225,13 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (maybeOntimeAction.action === 'message-secondary') {
|
if (maybeOntimeAction.action === 'message-secondary') {
|
||||||
// the secondary text is optional, an empty string is treated as no change
|
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
|
||||||
let text: string | undefined = undefined;
|
|
||||||
if ('text' in maybeOntimeAction) {
|
|
||||||
assert.isString(maybeOntimeAction.text);
|
|
||||||
text = indeterminateText(maybeOntimeAction.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
|
|
||||||
return {
|
|
||||||
type: 'ontime',
|
|
||||||
action: 'message-secondary',
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// null is used to clear the secondary source
|
// null is used to clear the secondary source
|
||||||
if (maybeOntimeAction.secondarySource === null) {
|
if (maybeOntimeAction.secondarySource === null) {
|
||||||
return {
|
return {
|
||||||
type: 'ontime',
|
type: 'ontime',
|
||||||
action: 'message-secondary',
|
action: 'message-secondary',
|
||||||
secondarySource: null,
|
secondarySource: null,
|
||||||
text,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +240,6 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
|||||||
type: 'ontime',
|
type: 'ontime',
|
||||||
action: 'message-secondary',
|
action: 'message-secondary',
|
||||||
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
|
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
|
||||||
text,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
|
import { LogOrigin, OntimeAction } from 'ontime-types';
|
||||||
import { parseUserTime } from 'ontime-utils';
|
import { parseUserTime } from 'ontime-utils';
|
||||||
import { DeepReadonly } from 'ts-essentials';
|
|
||||||
|
|
||||||
import { logger } from '../../../classes/Logger.js';
|
import { logger } from '../../../classes/Logger.js';
|
||||||
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
|
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
|
||||||
import * as messageService from '../../../services/message-service/message.service.js';
|
import * as messageService from '../../../services/message-service/message.service.js';
|
||||||
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
|
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
|
||||||
import { parseTemplateNested } from '../automation.utils.js';
|
|
||||||
|
|
||||||
export function toOntimeAction(action: OntimeAction, store: DeepReadonly<RuntimeStore>) {
|
export function toOntimeAction(action: OntimeAction) {
|
||||||
const actionType = action.action;
|
const actionType = action.action;
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
// Aux timer actions
|
// Aux timer actions
|
||||||
@@ -57,25 +55,18 @@ export function toOntimeAction(action: OntimeAction, store: DeepReadonly<Runtime
|
|||||||
case 'message-set': {
|
case 'message-set': {
|
||||||
messageService.patch({
|
messageService.patch({
|
||||||
timer: {
|
timer: {
|
||||||
text: action.text ? parseTemplateNested(action.text, store) : action.text,
|
text: action.text,
|
||||||
visible: action.visible,
|
visible: action.visible,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'message-secondary': {
|
case 'message-secondary': {
|
||||||
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
|
messageService.patch({
|
||||||
const patch =
|
timer: {
|
||||||
action.secondarySource === undefined
|
secondarySource: action.secondarySource,
|
||||||
? { secondary }
|
},
|
||||||
: {
|
});
|
||||||
timer: {
|
|
||||||
secondarySource: action.secondarySource,
|
|
||||||
},
|
|
||||||
secondary,
|
|
||||||
};
|
|
||||||
|
|
||||||
messageService.patch(patch);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -432,40 +432,6 @@ describe('processRundown()', () => {
|
|||||||
expect(initResult.totalDuration).toBe(500 - 100);
|
expect(initResult.totalDuration).toBe(500 - 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skipped events do not advance day offsets or affect gaps', () => {
|
|
||||||
const rundown = makeRundown({
|
|
||||||
order: ['1', 'skipped', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({
|
|
||||||
id: '1',
|
|
||||||
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
|
|
||||||
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
|
||||||
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
|
|
||||||
}),
|
|
||||||
skipped: makeOntimeEvent({
|
|
||||||
id: 'skipped',
|
|
||||||
skip: true,
|
|
||||||
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
|
|
||||||
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
|
||||||
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
|
|
||||||
}),
|
|
||||||
'2': makeOntimeEvent({
|
|
||||||
id: '2',
|
|
||||||
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
|
||||||
timeEnd: 8 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 08:30:00
|
|
||||||
duration: 8 * MILLIS_PER_HOUR, // 08:00:00
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const initResult = processRundown(rundown, {});
|
|
||||||
|
|
||||||
expect((initResult.entries.skipped as OntimeEvent).dayOffset).toBe(0);
|
|
||||||
expect((initResult.entries.skipped as OntimeEvent).gap).toBe(0);
|
|
||||||
expect((initResult.entries['2'] as OntimeEvent).dayOffset).toBe(1);
|
|
||||||
expect((initResult.entries['2'] as OntimeEvent).gap).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calculates total duration across days with gap', () => {
|
it('calculates total duration across days with gap', () => {
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
order: ['1', '2', '3'],
|
order: ['1', '2', '3'],
|
||||||
@@ -714,17 +680,17 @@ describe('processRundown()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('rundownMutation.add()', () => {
|
describe('rundownMutation.add()', () => {
|
||||||
test('adds an event to an empty rundown', () => {
|
test('adds an event an empty rundown', () => {
|
||||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||||
const rundown = makeRundown({});
|
const rundown = makeRundown({});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, null, null);
|
rundownMutation.add(rundown, mockEvent, null, null);
|
||||||
|
|
||||||
expect(rundown.order.length).toBe(1);
|
expect(rundown.order.length).toBe(1);
|
||||||
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds an event at the end if no afterId is given', () => {
|
test('adds an event at the top if no afterId is given', () => {
|
||||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
flatOrder: ['1'],
|
flatOrder: ['1'],
|
||||||
@@ -734,28 +700,28 @@ describe('rundownMutation.add()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, null, null);
|
rundownMutation.add(rundown, mockEvent, null, null);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1', 'mock']);
|
expect(rundown.order).toStrictEqual(['mock', '1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', 'mock']);
|
expect(rundown.flatOrder).toStrictEqual(['mock', '1']);
|
||||||
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds an event at the end of the group if parent is provided but no after', () => {
|
test('adds an event at the top of the group if no after is given', () => {
|
||||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
flatOrder: ['1', '1a'],
|
flatOrder: ['1', '1a'],
|
||||||
order: ['1'],
|
order: ['1'],
|
||||||
entries: {
|
entries: {
|
||||||
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
|
'1': makeOntimeGroup({ id: '1' }),
|
||||||
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, null, null);
|
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
|
||||||
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -765,12 +731,12 @@ describe('rundownMutation.add()', () => {
|
|||||||
flatOrder: ['1', '1a'],
|
flatOrder: ['1', '1a'],
|
||||||
order: ['1'],
|
order: ['1'],
|
||||||
entries: {
|
entries: {
|
||||||
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
|
'1': makeOntimeGroup({ id: '1' }),
|
||||||
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, '1a', null);
|
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
||||||
@@ -1839,49 +1805,6 @@ describe('rundownMutation.clone()', () => {
|
|||||||
parent: null,
|
parent: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clones an event and appends it with after true', () => {
|
|
||||||
const testRundown = makeRundown({
|
|
||||||
order: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
|
|
||||||
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
|
|
||||||
|
|
||||||
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clones an event and prepends it with before true', () => {
|
|
||||||
const testRundown = makeRundown({
|
|
||||||
order: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
|
|
||||||
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['2'], { before: true });
|
|
||||||
|
|
||||||
expect(testRundown.order).toStrictEqual([newEntry.id, '1', '2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clones a group and appends it with after true', () => {
|
|
||||||
const testRundown = makeRundown({
|
|
||||||
order: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeGroup({ id: '1', title: 'top', entries: ['1a'] }),
|
|
||||||
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
|
|
||||||
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
|
|
||||||
|
|
||||||
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rundownMutation.group()', () => {
|
describe('rundownMutation.group()', () => {
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import {
|
import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||||
EndAction,
|
|
||||||
OntimeEvent,
|
|
||||||
OntimeGroup,
|
|
||||||
OntimeMilestone,
|
|
||||||
TimeStrategy,
|
|
||||||
TimerLifeCycle,
|
|
||||||
TimerType,
|
|
||||||
Trigger,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
import { parseRundown } from '../rundown.parser.js';
|
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
deleteById,
|
deleteById,
|
||||||
@@ -20,8 +10,6 @@ import {
|
|||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
mergeRundownPreservingFields,
|
|
||||||
isLoadedPlayable,
|
|
||||||
} from '../rundown.utils.js';
|
} from '../rundown.utils.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
@@ -299,314 +287,3 @@ describe('getIntegerAndFraction()', () => {
|
|||||||
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
|
|
||||||
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
|
|
||||||
* any field it does not provide (e.g. automations) is kept from the existing event.
|
|
||||||
*/
|
|
||||||
describe('mergeRundownPreservingFields()', () => {
|
|
||||||
const automation: Trigger = {
|
|
||||||
id: 'trigger-onair',
|
|
||||||
title: 'Go on air',
|
|
||||||
trigger: TimerLifeCycle.onStart,
|
|
||||||
automationId: 'automation-onair',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
title: 'Main show',
|
|
||||||
revision: 3,
|
|
||||||
order: ['welcome', 'keynote'],
|
|
||||||
entries: {
|
|
||||||
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
|
||||||
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
title: 'From spreadsheet',
|
|
||||||
revision: 0,
|
|
||||||
order: ['welcome', 'lunch'],
|
|
||||||
entries: {
|
|
||||||
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
|
||||||
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
|
|
||||||
// identity and revision come from the current rundown
|
|
||||||
expect(merged.id).toBe('show-rundown');
|
|
||||||
expect(merged.title).toBe('Main show');
|
|
||||||
expect(merged.revision).toBe(4);
|
|
||||||
// structure and order come from the incoming rundown
|
|
||||||
expect(merged.order).toEqual(['welcome', 'lunch']);
|
|
||||||
expect(merged.flatOrder).toEqual(incoming.flatOrder);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes current entries that are absent from the incoming rundown', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['welcome', 'keynote'],
|
|
||||||
entries: {
|
|
||||||
welcome: makeOntimeEvent({ id: 'welcome' }),
|
|
||||||
keynote: makeOntimeEvent({ id: 'keynote' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['welcome'],
|
|
||||||
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
|
|
||||||
expect(merged.entries.welcome).toBeDefined();
|
|
||||||
expect(merged.entries.keynote).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
|
|
||||||
// the incoming group fully replaces the previous event, no old data is carried over
|
|
||||||
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
// the sheet mapped title and note
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
|
|
||||||
const keynote = merged.entries.keynote as OntimeEvent;
|
|
||||||
|
|
||||||
expect(keynote.title).toBe('Opening keynote');
|
|
||||||
// an empty provided value replaces the current one
|
|
||||||
expect(keynote.note).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: {
|
|
||||||
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
|
|
||||||
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: {
|
|
||||||
keynote: makeOntimeEvent({
|
|
||||||
id: 'keynote',
|
|
||||||
title: 'Opening keynote',
|
|
||||||
note: 'from a stale column',
|
|
||||||
triggers: [],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// only title is mapped, so note and automations keep the existing values, not the incoming ones
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
const keynote = merged.entries.keynote as OntimeEvent;
|
|
||||||
|
|
||||||
expect(keynote.title).toBe('Opening keynote');
|
|
||||||
expect(keynote.note).toBe('green room');
|
|
||||||
expect(keynote.triggers).toEqual([automation]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: {
|
|
||||||
keynote: makeOntimeEvent({
|
|
||||||
id: 'keynote',
|
|
||||||
title: 'Keynote',
|
|
||||||
custom: { lighting: 'warm', song: 'intro theme' },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: {
|
|
||||||
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// the sheet mapped the title and the lighting custom field, but not song
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
|
|
||||||
const keynote = merged.entries.keynote as OntimeEvent;
|
|
||||||
|
|
||||||
expect(keynote.title).toBe('Opening keynote');
|
|
||||||
expect(keynote.custom.lighting).toBe('cold');
|
|
||||||
// an unmapped custom field is kept from the existing event
|
|
||||||
expect(keynote.custom.song).toBe('intro theme');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
|
||||||
});
|
|
||||||
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
|
|
||||||
|
|
||||||
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the existing time strategy when the provided times are ambiguous', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
|
||||||
});
|
|
||||||
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
|
|
||||||
|
|
||||||
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges a matched group, keeping the fields the sheet cannot express', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['session'],
|
|
||||||
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
|
|
||||||
});
|
|
||||||
// the sheet cannot express a group's target duration, so the incoming group does not carry one
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['session'],
|
|
||||||
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
const session = merged.entries.session as OntimeGroup;
|
|
||||||
|
|
||||||
expect(session.title).toBe('New session');
|
|
||||||
expect(session.targetDuration).toBe(3_600_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['reminder'],
|
|
||||||
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['reminder'],
|
|
||||||
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
// only title is mapped, so the milestone's note keeps the existing value
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
const reminder = merged.entries.reminder as OntimeMilestone;
|
|
||||||
|
|
||||||
expect(reminder.title).toBe('Green room reminder');
|
|
||||||
expect(reminder.note).toBe('call talent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate the current rundown and deep-clones the kept automations', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
|
|
||||||
// kept automations are a copy, not a shared reference to the current rundown
|
|
||||||
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
|
|
||||||
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the not-provided fields through a parseRundown round-trip', () => {
|
|
||||||
const current = makeRundown({
|
|
||||||
id: 'show-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: {
|
|
||||||
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const incoming = makeRundown({
|
|
||||||
id: 'spreadsheet-rundown',
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
||||||
const parsed = parseRundown(merged, {});
|
|
||||||
const keynote = parsed.entries.keynote as OntimeEvent;
|
|
||||||
|
|
||||||
expect(keynote.triggers).toEqual([automation]);
|
|
||||||
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isLoadedPlayable()', () => {
|
|
||||||
it('returns true when the loaded event still exists and is playable', () => {
|
|
||||||
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
|
|
||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when the loaded event was removed', () => {
|
|
||||||
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
|
|
||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when the loaded event is now skipped', () => {
|
|
||||||
const rundown = makeRundown({
|
|
||||||
order: ['keynote'],
|
|
||||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
|
|
||||||
});
|
|
||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when the matched entry is no longer an event', () => {
|
|
||||||
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
|
|
||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { OntimeGroup } from 'ontime-types';
|
|
||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
|
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
|
||||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder } from '../rundown.validation.js';
|
|
||||||
|
|
||||||
const rundown = makeRundown({
|
|
||||||
order: ['top', 'group'],
|
|
||||||
entries: {
|
|
||||||
top: makeOntimeEvent({ id: 'top', parent: null }),
|
|
||||||
group: makeOntimeGroup({ id: 'group', entries: ['nested'] }),
|
|
||||||
nested: makeOntimeEvent({ id: 'nested', parent: 'group' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('insertion anchor validation', () => {
|
|
||||||
test('rejects a missing anchor', () => {
|
|
||||||
expect(() => assertInsertAnchorInOrder(rundown, null, { before: 'missing' })).toThrow(
|
|
||||||
'Insertion anchor with ID missing does not exist',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects an anchor from a different order', () => {
|
|
||||||
expect(() => assertInsertAnchorInOrder(rundown, null, { after: 'nested' })).toThrow(
|
|
||||||
'Insertion anchor with ID nested is not in the target order',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts a group-local anchor', () => {
|
|
||||||
const group = rundown.entries.group as OntimeGroup;
|
|
||||||
|
|
||||||
expect(() => assertInsertAnchorInOrder(rundown, group, { before: 'nested' })).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checks clone anchors exist', () => {
|
|
||||||
expect(() => assertInsertAnchorExists(rundown, { after: 'missing' })).toThrow(
|
|
||||||
'Insertion anchor with ID missing does not exist',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -439,27 +439,21 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
|
|||||||
rundown.entries[nestedEntry.id] = nestedEntry;
|
rundown.entries[nestedEntry.id] = nestedEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position: { after: EntryId | null; before: EntryId | null } = (() => {
|
// by default we insert after the cloned element
|
||||||
if (options?.after === undefined && options?.before === undefined) {
|
let atIndex = rundown.order.indexOf(entry.id) + 1;
|
||||||
return { after: entry.id, before: null };
|
|
||||||
|
const referenceId = options?.after ?? options?.before;
|
||||||
|
if (referenceId) {
|
||||||
|
// trying to insert relatively to another entry
|
||||||
|
const referenceEntry = rundown.entries[referenceId];
|
||||||
|
if (referenceEntry) {
|
||||||
|
if (options?.after) {
|
||||||
|
atIndex = rundown.order.indexOf(referenceId) + 1;
|
||||||
|
} else if (options?.before) {
|
||||||
|
atIndex = rundown.order.indexOf(referenceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const after = getInsertAfterId(rundown, null, options.after, options.before);
|
|
||||||
const before = options.before === true ? (after ?? null) : (options.before ?? null);
|
|
||||||
return { after, before };
|
|
||||||
})();
|
|
||||||
|
|
||||||
const atIndex = (() => {
|
|
||||||
if (position.before !== null) {
|
|
||||||
return rundown.order.indexOf(position.before);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (position.after !== null) {
|
|
||||||
return rundown.order.indexOf(position.after) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rundown.order.length;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// we only need to insert the group, the nested entries will be resolved by the rundown engine
|
// we only need to insert the group, the nested entries will be resolved by the rundown engine
|
||||||
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
|
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
|
||||||
@@ -467,33 +461,41 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
|
|||||||
return newGroup;
|
return newGroup;
|
||||||
} else {
|
} else {
|
||||||
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
|
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
|
||||||
const parent = (() => {
|
|
||||||
const referenceId = (() => {
|
|
||||||
if (typeof options?.after === 'string') return options.after;
|
|
||||||
if (typeof options?.before === 'string') return options.before;
|
|
||||||
return undefined;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const referenceEntry = referenceId ? rundown.entries[referenceId] : entry;
|
let parent: OntimeGroup | null = null;
|
||||||
const parentId = referenceEntry && !isOntimeGroup(referenceEntry) ? referenceEntry.parent : null;
|
|
||||||
if (parentId === null) return null;
|
|
||||||
|
|
||||||
const maybeParent = rundown.entries[parentId];
|
// trying to insert relatively to another entry, check that entries parent
|
||||||
return isOntimeGroup(maybeParent) ? maybeParent : null;
|
const referenceId = options?.after ?? options?.before;
|
||||||
})();
|
|
||||||
|
|
||||||
const position = (() => {
|
/**
|
||||||
// if no position is given, we add immediately after the element
|
* if we have a positioning reference, and that reference has a parent
|
||||||
if (options?.after === undefined && options?.before === undefined) {
|
* we need to maintain the same parent for the cloned entry
|
||||||
return { after: entry.id, before: null };
|
*/
|
||||||
|
if (referenceId) {
|
||||||
|
const referenceEntry = rundown.entries[referenceId];
|
||||||
|
|
||||||
|
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
|
||||||
|
if (referenceEntry.parent) {
|
||||||
|
const maybeParent = rundown.entries[referenceEntry.parent];
|
||||||
|
if (maybeParent && isOntimeGroup(maybeParent)) {
|
||||||
|
parent = maybeParent;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (entry.parent) {
|
||||||
|
const maybeParent = rundown.entries[entry.parent];
|
||||||
|
if (maybeParent && isOntimeGroup(maybeParent)) {
|
||||||
|
parent = maybeParent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const after = getInsertAfterId(rundown, parent, options.after, options.before);
|
// if we have resolved a parent, we add it to the cloned entry
|
||||||
const before = options?.before === true ? after : (options.before ?? null);
|
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
|
||||||
return { after, before };
|
if (!after) {
|
||||||
})();
|
after = entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
return addToRundown(rundown, clonedEntry, parent, position.after, position.before);
|
return addToRundown(rundown, clonedEntry, after, parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,22 +279,14 @@ function processEntry<T extends OntimeEntry>(
|
|||||||
// 2. handle custom fields - mutates entry
|
// 2. handle custom fields - mutates entry
|
||||||
sanitiseCustomFields(customFields, entry);
|
sanitiseCustomFields(customFields, entry);
|
||||||
|
|
||||||
/*
|
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
||||||
* we initialise data so that is not calculated for skipped events
|
entry.dayOffset = rundownMetadata.totalDays as Day;
|
||||||
* consider especially the day offset, while skipped events have no
|
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||||
* day offset, we match it to the previous element to avoid
|
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||||
* pushing confusing data to the UI
|
|
||||||
*/
|
|
||||||
entry.dayOffset = (rundownMetadata.previousEvent?.dayOffset ?? 0) as Day;
|
|
||||||
entry.delay = 0;
|
|
||||||
entry.gap = 0;
|
|
||||||
|
|
||||||
entry.parent = childOfGroup;
|
entry.parent = childOfGroup;
|
||||||
|
|
||||||
// update rundown metadata, it only concerns playable events
|
// update rundown metadata, it only concerns playable events
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
|
||||||
entry.dayOffset = rundownMetadata.totalDays as Day;
|
|
||||||
rundownMetadata.playableEventOrder.push(entry.id);
|
rundownMetadata.playableEventOrder.push(entry.id);
|
||||||
|
|
||||||
// first start is always the first event
|
// first start is always the first event
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import type { Request, Response, Router } from 'express';
|
import type { Request, Response, Router } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { matchedData } from 'express-validator';
|
import { matchedData } from 'express-validator';
|
||||||
import {
|
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
|
||||||
ErrorResponse,
|
|
||||||
OntimeEntry,
|
|
||||||
ProjectRundowns,
|
|
||||||
ProjectRundownsList,
|
|
||||||
RenumberCues,
|
|
||||||
Rundown,
|
|
||||||
RundownImportPayload,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
@@ -20,9 +12,7 @@ import {
|
|||||||
applyDelay,
|
applyDelay,
|
||||||
batchEditEntries,
|
batchEditEntries,
|
||||||
cloneEntry,
|
cloneEntry,
|
||||||
applyImportToRundown,
|
|
||||||
createNewRundown,
|
createNewRundown,
|
||||||
createRundownFromImport,
|
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
deleteRundown,
|
deleteRundown,
|
||||||
@@ -46,7 +36,6 @@ import {
|
|||||||
entryReorderValidator,
|
entryReorderValidator,
|
||||||
entrySwapValidator,
|
entrySwapValidator,
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
rundownImportValidator,
|
|
||||||
rundownPatchValidator,
|
rundownPatchValidator,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
} from './rundown.validation.js';
|
} from './rundown.validation.js';
|
||||||
@@ -158,39 +147,6 @@ router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRu
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies an imported rundown: override or merge into an existing rundown, or create a new one.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
'/import',
|
|
||||||
rundownImportValidator,
|
|
||||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
|
||||||
try {
|
|
||||||
const { mode, targetRundownId, rundown, customFields, providedFields } = matchedData<RundownImportPayload>(req);
|
|
||||||
let projectRundowns: ProjectRundowns;
|
|
||||||
if (mode === 'new') {
|
|
||||||
projectRundowns = await createRundownFromImport(rundown, customFields);
|
|
||||||
} else {
|
|
||||||
// the validator guarantees this for override/merge, the guard narrows the type and adds defence in depth
|
|
||||||
if (!targetRundownId) {
|
|
||||||
throw new Error('targetRundownId is required when mode is override or merge');
|
|
||||||
}
|
|
||||||
projectRundowns = await applyImportToRundown(
|
|
||||||
mode,
|
|
||||||
targetRundownId,
|
|
||||||
rundown,
|
|
||||||
customFields,
|
|
||||||
providedFields ?? { event: [], custom: [] },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
|
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
CustomFields,
|
CustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
EventPostPayload,
|
EventPostPayload,
|
||||||
ImportedFields,
|
|
||||||
InsertOptions,
|
InsertOptions,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
Rundown,
|
Rundown,
|
||||||
RundownImportMergeStrategy,
|
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
@@ -27,7 +25,6 @@ import { makeNewRundown } from '../../models/dataModel.js';
|
|||||||
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
|
||||||
import {
|
import {
|
||||||
createTransaction,
|
createTransaction,
|
||||||
customFieldMutation,
|
customFieldMutation,
|
||||||
@@ -36,19 +33,8 @@ import {
|
|||||||
rundownMutation,
|
rundownMutation,
|
||||||
updateBackgroundRundown,
|
updateBackgroundRundown,
|
||||||
} from './rundown.dao.js';
|
} from './rundown.dao.js';
|
||||||
import { parseRundown } from './rundown.parser.js';
|
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import {
|
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
|
||||||
generateEvent,
|
|
||||||
getFirstInsertId,
|
|
||||||
getIntegerAndFraction,
|
|
||||||
getLastInsertId,
|
|
||||||
getPreviousInsertId,
|
|
||||||
hasChanges,
|
|
||||||
mergeRundownPreservingFields,
|
|
||||||
isLoadedPlayable,
|
|
||||||
} from './rundown.utils.js';
|
|
||||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* creates a new entry with given data
|
* creates a new entry with given data
|
||||||
@@ -56,8 +42,6 @@ import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsert
|
|||||||
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
|
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||||
|
|
||||||
assertSingleInsertAnchor(eventData);
|
|
||||||
|
|
||||||
// we allow the user to provide an ID, but make sure it is unique
|
// we allow the user to provide an ID, but make sure it is unique
|
||||||
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
||||||
throw new Error(`Event with ID ${eventData.id} already exists`);
|
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||||
@@ -74,29 +58,14 @@ export async function addEntry(rundownId: string, eventData: EventPostPayload):
|
|||||||
parent = maybeParent;
|
parent = maybeParent;
|
||||||
}
|
}
|
||||||
|
|
||||||
assertInsertAnchorInOrder(rundown, parent, eventData);
|
|
||||||
|
|
||||||
// normalise the position of the event in the rundown order
|
// normalise the position of the event in the rundown order
|
||||||
const insertPosition = (() => {
|
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
|
||||||
if (eventData.before !== undefined) {
|
|
||||||
const beforeId = getFirstInsertId(rundown, parent, eventData.before);
|
|
||||||
return { afterId: null, beforeId, cueAfterId: getPreviousInsertId(rundown, parent, beforeId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// if after is an ID, we will try and find the entry
|
|
||||||
if (eventData.after !== undefined && eventData.after !== true) {
|
|
||||||
const afterId = getInsertAfterId(rundown, parent, eventData.after);
|
|
||||||
return { afterId, beforeId: null, cueAfterId: afterId };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { afterId: null, beforeId: null, cueAfterId: getLastInsertId(rundown, parent) };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
// generate a fully formed entry from the patch
|
||||||
const newEntry = generateEvent(rundown, eventData, insertPosition.cueAfterId, parent?.id);
|
const newEntry = generateEvent(rundown, eventData, afterId, parent?.id);
|
||||||
|
|
||||||
// make mutations to rundown
|
// make mutations to rundown
|
||||||
rundownMutation.add(rundown, newEntry, parent, insertPosition.afterId, insertPosition.beforeId);
|
rundownMutation.add(rundown, newEntry, afterId, parent);
|
||||||
|
|
||||||
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
|
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
|
||||||
|
|
||||||
@@ -416,10 +385,7 @@ export async function swapEvents(rundownId: string, fromId: EntryId, toId: Entry
|
|||||||
* @throws if the entry to clone does not exist
|
* @throws if the entry to clone does not exist
|
||||||
*/
|
*/
|
||||||
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
||||||
assertSingleInsertAnchor(options);
|
|
||||||
|
|
||||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||||
assertInsertAnchorExists(rundown, options);
|
|
||||||
const originalEntry = rundown.entries[entryId];
|
const originalEntry = rundown.entries[entryId];
|
||||||
|
|
||||||
if (!originalEntry) {
|
if (!originalEntry) {
|
||||||
@@ -690,8 +656,8 @@ export async function loadRundown(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a new rundown in the cache and marks it as the currently loaded one.
|
* Sets a new rundown in the cache
|
||||||
* Switching to a rundown always stops playback.
|
* and marks it as the currently loaded one
|
||||||
*/
|
*/
|
||||||
export async function initRundown(
|
export async function initRundown(
|
||||||
rundown: Readonly<Rundown>,
|
rundown: Readonly<Rundown>,
|
||||||
@@ -713,25 +679,6 @@ export async function initRundown(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a rebuilt version of the currently loaded rundown in place.
|
|
||||||
* Unlike switching rundowns, this maintains playback when possible
|
|
||||||
*/
|
|
||||||
function applyChangeToCurrentRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
|
||||||
const loadedEvent = runtimeService.getLoadedEventId();
|
|
||||||
if (loadedEvent && !isLoadedPlayable(loadedEvent, rundown)) {
|
|
||||||
runtimeService.stop();
|
|
||||||
}
|
|
||||||
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
|
||||||
updateRuntimeOnChange(rundownMetadata);
|
|
||||||
|
|
||||||
setImmediate(() => {
|
|
||||||
// notifying the timer hot-reloads the playing event and keeps playback
|
|
||||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload: true });
|
|
||||||
sendRefetch(RefetchKey.ProjectRundowns);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createNewRundown(title: string) {
|
export async function createNewRundown(title: string) {
|
||||||
const emptyRundown = makeNewRundown();
|
const emptyRundown = makeNewRundown();
|
||||||
emptyRundown.title = title;
|
emptyRundown.title = title;
|
||||||
@@ -796,86 +743,6 @@ export async function duplicateExistingRundown(id: string) {
|
|||||||
return dataProvider.getProjectRundowns();
|
return dataProvider.getProjectRundowns();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an imported rundown against the resulting custom fields, then commits those custom
|
|
||||||
* fields. The rundown is validated before the custom-field mutation, so a payload that fails
|
|
||||||
* validation cannot leave a partial custom-field write behind.
|
|
||||||
* @throws if the rundown or custom fields fail validation
|
|
||||||
*/
|
|
||||||
async function parseImportAndCommitCustomFields(
|
|
||||||
source: Readonly<Rundown>,
|
|
||||||
incomingCustomFields: CustomFields,
|
|
||||||
): Promise<Rundown> {
|
|
||||||
const dataProvider = getDataProvider();
|
|
||||||
const parsedCustomFields = parseCustomFields({ customFields: incomingCustomFields });
|
|
||||||
const mergedCustomFields = { ...dataProvider.getCustomFields(), ...parsedCustomFields };
|
|
||||||
const parsed = parseRundown(source, mergedCustomFields);
|
|
||||||
await dataProvider.mergeIntoData({ customFields: parsedCustomFields });
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies an imported rundown onto an existing rundown, keeping the existing identity while
|
|
||||||
* taking structure and order from the incoming data.
|
|
||||||
* - 'override' replaces all content with the incoming data
|
|
||||||
* - 'merge' updates matched entries with only the fields the spreadsheet provided, keeping the rest
|
|
||||||
* (e.g. automations) from the existing entry
|
|
||||||
*
|
|
||||||
* When targeting the loaded rundown this is treated as a change, not a switch, so playback is
|
|
||||||
* maintained when the playing event survives.
|
|
||||||
* @throws if the target rundown does not exist
|
|
||||||
*/
|
|
||||||
export async function applyImportToRundown(
|
|
||||||
strategy: RundownImportMergeStrategy,
|
|
||||||
targetRundownId: string,
|
|
||||||
incomingRundown: Rundown,
|
|
||||||
incomingCustomFields: CustomFields,
|
|
||||||
providedFields: ImportedFields,
|
|
||||||
): Promise<ProjectRundowns> {
|
|
||||||
const dataProvider = getDataProvider();
|
|
||||||
// throws if the rundown was deleted between preview and apply
|
|
||||||
const existing = dataProvider.getRundown(targetRundownId);
|
|
||||||
|
|
||||||
const source =
|
|
||||||
strategy === 'merge'
|
|
||||||
? mergeRundownPreservingFields(incomingRundown, existing, providedFields)
|
|
||||||
: { ...incomingRundown, id: existing.id, title: existing.title, revision: existing.revision + 1 };
|
|
||||||
const parsed = await parseImportAndCommitCustomFields(source, incomingCustomFields);
|
|
||||||
|
|
||||||
if (isCurrentRundown(targetRundownId)) {
|
|
||||||
// applying to the loaded rundown is a change, not a switch: maintain playback when possible
|
|
||||||
applyChangeToCurrentRundown(parsed, dataProvider.getCustomFields());
|
|
||||||
} else {
|
|
||||||
await dataProvider.setRundown(parsed.id, parsed);
|
|
||||||
setImmediate(() => {
|
|
||||||
sendRefetch(RefetchKey.ProjectRundowns);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataProvider.getProjectRundowns();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new rundown from an imported rundown and loads it,
|
|
||||||
* so the user immediately sees the imported data.
|
|
||||||
* Loading a rundown stops playback (as with any rundown switch).
|
|
||||||
*/
|
|
||||||
export async function createRundownFromImport(
|
|
||||||
incomingRundown: Rundown,
|
|
||||||
incomingCustomFields: CustomFields,
|
|
||||||
): Promise<ProjectRundowns> {
|
|
||||||
const dataProvider = getDataProvider();
|
|
||||||
|
|
||||||
// assign a fresh id so we never collide with an existing rundown
|
|
||||||
const parsed = await parseImportAndCommitCustomFields({ ...incomingRundown, id: generateId() }, incomingCustomFields);
|
|
||||||
parsed.revision = 0;
|
|
||||||
|
|
||||||
// initRundown persists the new rundown, makes it the loaded rundown and notifies clients
|
|
||||||
await initRundown(parsed, dataProvider.getCustomFields(), true);
|
|
||||||
|
|
||||||
return dataProvider.getProjectRundowns();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a rundown
|
* Deletes a rundown
|
||||||
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
CustomFields,
|
CustomFields,
|
||||||
EntryCustomFields,
|
EntryCustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
ImportedFields,
|
|
||||||
OntimeBaseEvent,
|
OntimeBaseEvent,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -12,14 +11,12 @@ import {
|
|||||||
ProjectRundown,
|
ProjectRundown,
|
||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
Rundown,
|
Rundown,
|
||||||
RundownEntries,
|
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isOntimeMilestone,
|
isOntimeMilestone,
|
||||||
isPlayableEvent,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
createDelay,
|
createDelay,
|
||||||
@@ -70,50 +67,6 @@ export function generateEvent(
|
|||||||
throw new Error('Invalid event type');
|
throw new Error('Invalid event type');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the last valid insertion reference for a top-level rundown or group order.
|
|
||||||
* Used when appending entries and when generating cues from the preceding entry.
|
|
||||||
*/
|
|
||||||
export function getLastInsertId(rundown: Rundown, parent: OntimeGroup | null): EntryId | null {
|
|
||||||
const insertionList = parent ? parent.entries : rundown.order;
|
|
||||||
return insertionList[insertionList.length - 1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a `before` insertion option to the first entry in the relevant order when `true` is provided.
|
|
||||||
* String values are already explicit anchors and are returned unchanged.
|
|
||||||
*/
|
|
||||||
export function getFirstInsertId(rundown: Rundown, parent: OntimeGroup | null, before: EntryId | true): EntryId | null {
|
|
||||||
if (before !== true) {
|
|
||||||
return before;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertionList = parent ? parent.entries : rundown.order;
|
|
||||||
return insertionList[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the sibling before a `before` insertion anchor in the top-level or group order.
|
|
||||||
* Returns `null` when the new entry will be inserted at the start.
|
|
||||||
*/
|
|
||||||
export function getPreviousInsertId(
|
|
||||||
rundown: Rundown,
|
|
||||||
parent: OntimeGroup | null,
|
|
||||||
beforeId: EntryId | null,
|
|
||||||
): EntryId | null {
|
|
||||||
if (beforeId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertionList = parent ? parent.entries : rundown.order;
|
|
||||||
const beforeIndex = insertionList.indexOf(beforeId);
|
|
||||||
if (beforeIndex < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return insertionList[beforeIndex - 1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||||
if (Object.keys(patchEvent).length === 0) {
|
if (Object.keys(patchEvent).length === 0) {
|
||||||
return originalEvent;
|
return originalEvent;
|
||||||
@@ -272,83 +225,6 @@ export function getUniqueId(rundown: Rundown): EntryId {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds an entry patch containing exactly the fields the spreadsheet supplied, for both built-in
|
|
||||||
* and custom fields. Everything the sheet did not map is left out, so applying the patch keeps the
|
|
||||||
* existing value for those fields.
|
|
||||||
*/
|
|
||||||
function buildImportPatch(entry: OntimeEntry, providedFields: ImportedFields): Partial<OntimeEntry> {
|
|
||||||
const source = entry as Record<string, unknown>;
|
|
||||||
const patch: Record<string, unknown> = {};
|
|
||||||
for (const field of providedFields.event) {
|
|
||||||
patch[field] = source[field];
|
|
||||||
}
|
|
||||||
if (providedFields.custom.length > 0) {
|
|
||||||
const entryCustom = (source.custom ?? {}) as EntryCustomFields;
|
|
||||||
const custom: EntryCustomFields = {};
|
|
||||||
for (const key of providedFields.custom) {
|
|
||||||
custom[key] = entryCustom[key] ?? '';
|
|
||||||
}
|
|
||||||
patch.custom = custom;
|
|
||||||
}
|
|
||||||
return patch as Partial<OntimeEntry>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges an imported rundown into an existing one
|
|
||||||
* - the incoming rundown is the source of truth for entry identity and structure (order + grouping)
|
|
||||||
* - a matched entry of the same type is merged field-by-field: a field the sheet provided overwrites
|
|
||||||
* (even when empty), a field the sheet cannot express (an event's automations, a group's target
|
|
||||||
* duration, an unmapped custom field) is kept from the existing entry
|
|
||||||
* - a new id, or an id whose type changed, takes the incoming entry wholesale
|
|
||||||
* - existing entries absent from the incoming rundown are dropped
|
|
||||||
*/
|
|
||||||
export function mergeRundownPreservingFields(
|
|
||||||
incoming: Readonly<Rundown>,
|
|
||||||
existing: Readonly<Rundown>,
|
|
||||||
providedFields: ImportedFields,
|
|
||||||
): Rundown {
|
|
||||||
const entries: RundownEntries = {};
|
|
||||||
|
|
||||||
for (const [id, incomingEntry] of Object.entries(incoming.entries)) {
|
|
||||||
const existingEntry = existing.entries[id];
|
|
||||||
|
|
||||||
// a new id, or one whose type changed, is not compatible for a merge: take the incoming data
|
|
||||||
if (existingEntry === undefined || existingEntry.type !== incomingEntry.type) {
|
|
||||||
entries[id] = incomingEntry;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge the sheet's data onto the existing entry through the canonical patch function, which
|
|
||||||
// keeps every unmapped field and infers an event's time strategy from the provided times
|
|
||||||
const merged = applyPatchToEntry(existingEntry, buildImportPatch(incomingEntry, providedFields));
|
|
||||||
// grouping comes from the sheet structure, not a data column: a group owns its children, every
|
|
||||||
// other entry knows its parent
|
|
||||||
const structure = isOntimeGroup(incomingEntry)
|
|
||||||
? { entries: incomingEntry.entries }
|
|
||||||
: { parent: incomingEntry.parent };
|
|
||||||
entries[id] = structuredClone({ ...merged, ...structure });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: existing.id,
|
|
||||||
title: existing.title,
|
|
||||||
order: [...incoming.order],
|
|
||||||
flatOrder: [...incoming.flatOrder],
|
|
||||||
revision: existing.revision + 1,
|
|
||||||
entries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the currently playing event survives a change to its rundown,
|
|
||||||
* i.e. it still exists and is playable in the new version.
|
|
||||||
*/
|
|
||||||
export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundown>): boolean {
|
|
||||||
const entry = rundown.entries[loadedEventId];
|
|
||||||
return entry !== undefined && isOntimeEvent(entry) && isPlayableEvent(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List of event properties which do not need the rundown to be regenerated */
|
/** List of event properties which do not need the rundown to be regenerated */
|
||||||
enum RegenerateWhitelist {
|
enum RegenerateWhitelist {
|
||||||
'id', // adding it for completeness, users cannot change ID
|
'id', // adding it for completeness, users cannot change ID
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { body, param } from 'express-validator';
|
import { body, param } from 'express-validator';
|
||||||
import type { EntryId, InsertOptions, OntimeGroup, Rundown } from 'ontime-types';
|
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
@@ -12,53 +11,20 @@ export const rundownPatchValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const rundownImportValidator = [
|
|
||||||
body('mode').isString().isIn(['override', 'merge', 'new']),
|
|
||||||
body('targetRundownId')
|
|
||||||
.if(body('mode').isIn(['override', 'merge']))
|
|
||||||
.isString()
|
|
||||||
.trim()
|
|
||||||
.notEmpty()
|
|
||||||
.withMessage('targetRundownId is required when mode is override or merge'),
|
|
||||||
body('rundown').isObject(),
|
|
||||||
body('rundown.entries').isObject(),
|
|
||||||
body('rundown.order').isArray(),
|
|
||||||
body('rundown.flatOrder').isArray(),
|
|
||||||
body('customFields').isObject(),
|
|
||||||
body('providedFields').optional().isObject(),
|
|
||||||
body('providedFields.event').optional().isArray(),
|
|
||||||
body('providedFields.event.*').isString(),
|
|
||||||
body('providedFields.custom').optional().isArray(),
|
|
||||||
body('providedFields.custom.*').isString(),
|
|
||||||
requestValidationFunction,
|
|
||||||
];
|
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
export const entryPostValidator = [
|
export const entryPostValidator = [
|
||||||
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
||||||
body('after')
|
body('after').optional().isString(),
|
||||||
.optional()
|
body('before').optional().isString(),
|
||||||
.custom((value) => value === true || typeof value === 'string')
|
|
||||||
.withMessage('Allowed values for after are an ID or true.'),
|
|
||||||
body('before')
|
|
||||||
.optional()
|
|
||||||
.custom((value) => value === true || typeof value === 'string')
|
|
||||||
.withMessage('Allowed values for before are an ID or true.'),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const clonePostValidator = [
|
export const clonePostValidator = [
|
||||||
body('after')
|
body('after').optional().isString(),
|
||||||
.optional()
|
body('before').optional().isString(),
|
||||||
.custom((value) => value === true || typeof value === 'string')
|
|
||||||
.withMessage('Allowed values for after are an ID or true.'),
|
|
||||||
body('before')
|
|
||||||
.optional()
|
|
||||||
.custom((value) => value === true || typeof value === 'string')
|
|
||||||
.withMessage('Allowed values for before are an ID or true.'),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
@@ -106,44 +72,4 @@ export const entryRenumberValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures callers use a single insertion anchor so placement is unambiguous.
|
|
||||||
*/
|
|
||||||
export function assertSingleInsertAnchor(options: InsertOptions) {
|
|
||||||
if (options.after !== undefined && options.before !== undefined) {
|
|
||||||
throw new Error('Use only one insertion anchor: after or before');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures an ID anchor exists and belongs to the order it is intended to position within.
|
|
||||||
*/
|
|
||||||
export function assertInsertAnchorInOrder(rundown: Rundown, parent: OntimeGroup | null, options: InsertOptions) {
|
|
||||||
const anchor = getStringInsertAnchor(options);
|
|
||||||
if (anchor === undefined) return;
|
|
||||||
|
|
||||||
if (!Object.hasOwn(rundown.entries, anchor)) {
|
|
||||||
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertionList = parent ? parent.entries : rundown.order;
|
|
||||||
if (!insertionList.includes(anchor)) {
|
|
||||||
throw new Error(`Insertion anchor with ID ${anchor} is not in the target order`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensures an ID anchor refers to an entry in the rundown. */
|
|
||||||
export function assertInsertAnchorExists(rundown: Rundown, options: InsertOptions) {
|
|
||||||
const anchor = getStringInsertAnchor(options);
|
|
||||||
if (anchor !== undefined && !Object.hasOwn(rundown.entries, anchor)) {
|
|
||||||
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStringInsertAnchor(options: InsertOptions): EntryId | undefined {
|
|
||||||
if (typeof options.after === 'string') return options.after;
|
|
||||||
if (typeof options.before === 'string') return options.before;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion operations on rundown entries =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { runMiddleware } from '../../validation-utils/__tests__/testMiddleware.js';
|
||||||
|
import { validateGenerateUrl } from '../session.validation.js';
|
||||||
|
|
||||||
|
describe('validateGenerateUrl', () => {
|
||||||
|
it('accepts a valid payload and normalises req.body', () => {
|
||||||
|
const { nextCalled, req } = runMiddleware(validateGenerateUrl, {
|
||||||
|
body: {
|
||||||
|
baseUrl: 'https://ontime.example',
|
||||||
|
path: '/timer',
|
||||||
|
authenticate: true,
|
||||||
|
lockConfig: false,
|
||||||
|
lockNav: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
expect(req.body).toMatchObject({ baseUrl: 'https://ontime.example', path: '/timer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an optional preset field', () => {
|
||||||
|
const { nextCalled, req } = runMiddleware(validateGenerateUrl, {
|
||||||
|
body: {
|
||||||
|
baseUrl: 'https://ontime.example',
|
||||||
|
path: '/timer',
|
||||||
|
authenticate: true,
|
||||||
|
lockConfig: false,
|
||||||
|
lockNav: false,
|
||||||
|
preset: 'my-preset',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
expect(req.body.preset).toBe('my-preset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing required field with a 422', () => {
|
||||||
|
const { nextCalled, statusCode } = runMiddleware(validateGenerateUrl, {
|
||||||
|
body: { path: '/timer', authenticate: true, lockConfig: false, lockNav: false },
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(statusCode).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong-typed field', () => {
|
||||||
|
const { nextCalled, statusCode } = runMiddleware(validateGenerateUrl, {
|
||||||
|
body: {
|
||||||
|
baseUrl: 'https://ontime.example',
|
||||||
|
path: '/timer',
|
||||||
|
authenticate: 'yes', // should be boolean
|
||||||
|
lockConfig: false,
|
||||||
|
lockNav: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(statusCode).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import type { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types'
|
|||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import * as sessionService from './session.service.js';
|
import * as sessionService from './session.service.js';
|
||||||
|
import type { GenerateUrlInput } from './session.validation.js';
|
||||||
import { validateGenerateUrl } from './session.validation.js';
|
import { validateGenerateUrl } from './session.validation.js';
|
||||||
|
|
||||||
export const router: Router = express.Router();
|
export const router: Router = express.Router();
|
||||||
@@ -28,17 +29,21 @@ router.get('/info', (_req: Request, res: Response<GetInfo | ErrorResponse>) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/url', validateGenerateUrl, (req: Request, res: Response<GetUrl | ErrorResponse>) => {
|
router.post(
|
||||||
try {
|
'/url',
|
||||||
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
validateGenerateUrl,
|
||||||
authenticate: req.body.authenticate,
|
(req: Request<unknown, GetUrl | ErrorResponse, GenerateUrlInput>, res: Response<GetUrl | ErrorResponse>) => {
|
||||||
lockConfig: req.body.lockConfig,
|
try {
|
||||||
lockNav: req.body.lockNav,
|
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
||||||
preset: req.body.preset,
|
authenticate: req.body.authenticate,
|
||||||
});
|
lockConfig: req.body.lockConfig,
|
||||||
res.status(200).send({ url: url.toString() });
|
lockNav: req.body.lockNav,
|
||||||
} catch (error) {
|
preset: req.body.preset,
|
||||||
const message = getErrorMessage(error);
|
});
|
||||||
res.status(500).send({ message });
|
res.status(200).send({ url: url.toString() });
|
||||||
}
|
} catch (error) {
|
||||||
});
|
const message = getErrorMessage(error);
|
||||||
|
res.status(500).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { body } from 'express-validator';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { validateBody } from '../validation-utils/validate.js';
|
||||||
|
|
||||||
export const validateGenerateUrl = [
|
const generateUrlSchema = z.object({
|
||||||
body('baseUrl').isString().trim().notEmpty(),
|
baseUrl: z.string().trim().min(1),
|
||||||
body('path').isString().trim().notEmpty(),
|
path: z.string().trim().min(1),
|
||||||
|
authenticate: z.boolean(),
|
||||||
body('authenticate').isBoolean(),
|
lockConfig: z.boolean(),
|
||||||
body('lockConfig').isBoolean(),
|
lockNav: z.boolean(),
|
||||||
body('lockNav').isBoolean(),
|
preset: z.string().trim().min(1).optional(),
|
||||||
body('preset').optional().isString().trim().notEmpty(),
|
});
|
||||||
|
export type GenerateUrlInput = z.infer<typeof generateUrlSchema>;
|
||||||
requestValidationFunction,
|
export const validateGenerateUrl = validateBody(generateUrlSchema);
|
||||||
];
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { OntimeView } from 'ontime-types';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { runMiddleware } from '../../validation-utils/__tests__/testMiddleware.js';
|
||||||
|
import { validateNewPreset, validatePresetParam } from '../urlPresets.validation.js';
|
||||||
|
|
||||||
|
const validPreset = {
|
||||||
|
enabled: true,
|
||||||
|
alias: 'my-preset',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
displayInNav: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateNewPreset', () => {
|
||||||
|
it('accepts a valid preset', () => {
|
||||||
|
const { nextCalled } = runMiddleware(validateNewPreset, { body: validPreset });
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts optional cuesheet options', () => {
|
||||||
|
const { nextCalled, req } = runMiddleware(validateNewPreset, {
|
||||||
|
body: { ...validPreset, options: { read: 'a', write: 'b' } },
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
expect(req.body.options).toEqual({ read: 'a', write: 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing required field', () => {
|
||||||
|
const { alias: _alias, ...withoutAlias } = validPreset;
|
||||||
|
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, { body: withoutAlias });
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(statusCode).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects "editor" as a target — URL presets cannot point at the editor view', () => {
|
||||||
|
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, {
|
||||||
|
body: { ...validPreset, target: OntimeView.Editor },
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(statusCode).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown target value', () => {
|
||||||
|
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, {
|
||||||
|
body: { ...validPreset, target: 'not-a-real-view' },
|
||||||
|
});
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(statusCode).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validatePresetParam', () => {
|
||||||
|
it('accepts a non-empty alias param', () => {
|
||||||
|
const { nextCalled } = runMiddleware(validatePresetParam, { params: { alias: 'my-preset' } });
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty alias param', () => {
|
||||||
|
const { nextCalled, statusCode } = runMiddleware(validatePresetParam, { params: { alias: '' } });
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(statusCode).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { getErrorMessage } from 'ontime-utils';
|
|||||||
|
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import type { NewPresetInput, PresetAliasParam, UpdatePresetInput } from './urlPresets.validation.js';
|
||||||
import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js';
|
import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js';
|
||||||
|
|
||||||
export const router: Router = express.Router();
|
export const router: Router = express.Router();
|
||||||
@@ -14,80 +15,98 @@ router.get('/', (_req: Request, res: Response<URLPreset[]>) => {
|
|||||||
res.status(200).send(presets as URLPreset[]);
|
res.status(200).send(presets as URLPreset[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
router.post(
|
||||||
try {
|
'/',
|
||||||
const newPreset: URLPreset = {
|
validateNewPreset,
|
||||||
enabled: req.body.enabled,
|
async (
|
||||||
alias: req.body.alias,
|
req: Request<unknown, URLPreset[] | ErrorResponse, NewPresetInput>,
|
||||||
target: req.body.target,
|
res: Response<URLPreset[] | ErrorResponse>,
|
||||||
search: req.body.search,
|
) => {
|
||||||
displayInNav: req.body.displayInNav,
|
try {
|
||||||
options: req.body.options,
|
const newPreset: URLPreset = {
|
||||||
};
|
enabled: req.body.enabled,
|
||||||
|
alias: req.body.alias,
|
||||||
|
target: req.body.target,
|
||||||
|
search: req.body.search,
|
||||||
|
displayInNav: req.body.displayInNav,
|
||||||
|
options: req.body.options,
|
||||||
|
};
|
||||||
|
|
||||||
const currentPresets = getDataProvider().getUrlPresets();
|
const currentPresets = getDataProvider().getUrlPresets();
|
||||||
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
||||||
throw new Error(`Preset with alias ${newPreset.alias} already exists.`);
|
throw new Error(`Preset with alias ${newPreset.alias} already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPresets = [...currentPresets, newPreset];
|
||||||
|
|
||||||
|
// Update the URL presets in the data provider
|
||||||
|
await getDataProvider().setUrlPresets(newPresets);
|
||||||
|
sendRefetch(RefetchKey.UrlPresets);
|
||||||
|
res.status(201).send(newPresets);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const newPresets = [...currentPresets, newPreset];
|
router.put(
|
||||||
|
'/:alias',
|
||||||
|
validateUpdatePreset,
|
||||||
|
async (
|
||||||
|
req: Request<PresetAliasParam, URLPreset[] | ErrorResponse, UpdatePresetInput>,
|
||||||
|
res: Response<URLPreset[] | ErrorResponse>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const alias = req.params.alias;
|
||||||
|
const currentPresets = getDataProvider().getUrlPresets();
|
||||||
|
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||||
|
if (!existingPreset) {
|
||||||
|
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Update the URL presets in the data provider
|
const updatedPreset: URLPreset = {
|
||||||
await getDataProvider().setUrlPresets(newPresets);
|
enabled: req.body.enabled,
|
||||||
sendRefetch(RefetchKey.UrlPresets);
|
alias: req.body.alias,
|
||||||
res.status(201).send(newPresets);
|
target: req.body.target,
|
||||||
} catch (error) {
|
search: req.body.search,
|
||||||
const message = getErrorMessage(error);
|
displayInNav: req.body.displayInNav,
|
||||||
res.status(400).send({ message });
|
options: req.body.options ?? existingPreset.options,
|
||||||
}
|
};
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
if (alias !== updatedPreset.alias) {
|
||||||
try {
|
throw new Error('Changing alias is not permitted');
|
||||||
const alias = req.params.alias;
|
}
|
||||||
const currentPresets = getDataProvider().getUrlPresets();
|
|
||||||
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||||
if (!existingPreset) {
|
|
||||||
throw new Error(`Preset with alias ${alias} does not exist.`);
|
// Update the URL presets in the data provider
|
||||||
|
await getDataProvider().setUrlPresets(newPresets);
|
||||||
|
sendRefetch(RefetchKey.UrlPresets);
|
||||||
|
res.status(200).send(newPresets);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const updatedPreset: URLPreset = {
|
router.delete(
|
||||||
enabled: req.body.enabled,
|
'/:alias',
|
||||||
alias: req.body.alias,
|
validatePresetParam,
|
||||||
target: req.body.target,
|
async (req: Request<PresetAliasParam>, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||||
search: req.body.search,
|
try {
|
||||||
displayInNav: req.body.displayInNav,
|
const alias = req.params.alias;
|
||||||
options: req.body.options ?? existingPreset.options,
|
const currentPresets = getDataProvider().getUrlPresets();
|
||||||
};
|
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
||||||
|
|
||||||
if (alias !== updatedPreset.alias) {
|
// Update the URL presets in the data provider
|
||||||
throw new Error('Changing alias is not permitted');
|
await getDataProvider().setUrlPresets(newPresets);
|
||||||
|
sendRefetch(RefetchKey.UrlPresets);
|
||||||
|
res.status(200).send(newPresets);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
);
|
||||||
|
|
||||||
// Update the URL presets in the data provider
|
|
||||||
await getDataProvider().setUrlPresets(newPresets);
|
|
||||||
sendRefetch(RefetchKey.UrlPresets);
|
|
||||||
res.status(200).send(newPresets);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:alias', validatePresetParam, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
|
||||||
try {
|
|
||||||
const alias = req.params.alias;
|
|
||||||
const currentPresets = getDataProvider().getUrlPresets();
|
|
||||||
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
|
||||||
|
|
||||||
// Update the URL presets in the data provider
|
|
||||||
await getDataProvider().setUrlPresets(newPresets);
|
|
||||||
sendRefetch(RefetchKey.UrlPresets);
|
|
||||||
res.status(200).send(newPresets);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
import { body, param } from 'express-validator';
|
import { OntimeView, type OntimeViewPresettable } from 'ontime-types';
|
||||||
import { OntimeView } from 'ontime-types';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { validateBody, validateParams } from '../validation-utils/validate.js';
|
||||||
|
|
||||||
|
// URL presets cannot target the editor (see OntimeViewPresettable) — the previous
|
||||||
|
// express-validator check allowed any OntimeView value including 'editor', which URLPreset's
|
||||||
|
// own type never permitted; narrowed here now that the field is properly typed end to end.
|
||||||
|
const presettableViews = Object.values(OntimeView).filter(
|
||||||
|
(view): view is OntimeViewPresettable => view !== OntimeView.Editor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const presetOptionsSchema = z.record(z.string(), z.string()).optional();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validate array of URL preset objects
|
* validate array of URL preset objects
|
||||||
*/
|
*/
|
||||||
export const validateNewPreset = [
|
const newPresetSchema = z.object({
|
||||||
body().isObject().withMessage('No data found in request'),
|
enabled: z.boolean(),
|
||||||
body('enabled').isBoolean(),
|
alias: z.string().trim().min(1),
|
||||||
body('alias').isString().trim().notEmpty(),
|
target: z.enum(presettableViews),
|
||||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
search: z.string().trim(),
|
||||||
body('search').isString().trim(),
|
displayInNav: z.boolean(),
|
||||||
body('displayInNav').isBoolean(),
|
|
||||||
|
|
||||||
// options are currently only provided for cuesheet presets
|
// options are currently only provided for cuesheet presets
|
||||||
body('options').optional().isObject(),
|
options: presetOptionsSchema,
|
||||||
body('options.*').isString().trim(),
|
});
|
||||||
|
export type NewPresetInput = z.infer<typeof newPresetSchema>;
|
||||||
|
export const validateNewPreset = validateBody(newPresetSchema);
|
||||||
|
|
||||||
requestValidationFunction,
|
const presetAliasParamSchema = z.object({ alias: z.string().trim().min(1) });
|
||||||
];
|
export type PresetAliasParam = z.infer<typeof presetAliasParamSchema>;
|
||||||
|
export const validatePresetParam = validateParams(presetAliasParamSchema);
|
||||||
|
|
||||||
export const validateUpdatePreset = [
|
// update reuses the same body shape as create, plus the alias param check
|
||||||
param('alias').isString().trim().notEmpty(),
|
export type UpdatePresetInput = NewPresetInput;
|
||||||
body().isObject().withMessage('No data found in request'),
|
export const validateUpdatePreset = [validateParams(presetAliasParamSchema), validateBody(newPresetSchema)];
|
||||||
body('enabled').isBoolean(),
|
|
||||||
body('alias').isString().trim().notEmpty(),
|
|
||||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
|
||||||
body('search').isString().trim(),
|
|
||||||
body('displayInNav').isBoolean(),
|
|
||||||
|
|
||||||
// options are currently only provided for cuesheet presets
|
|
||||||
body('options').optional().isObject(),
|
|
||||||
body('options.*').isString().trim(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const validatePresetParam = [param('alias').isString().trim().notEmpty(), requestValidationFunction];
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises a single Express middleware (e.g. validateBody(schema)) against a minimal
|
||||||
|
* fake req/res, without standing up supertest or a running app — matches this codebase's
|
||||||
|
* convention of testing validation logic directly rather than through an HTTP layer.
|
||||||
|
*/
|
||||||
|
export function runMiddleware(
|
||||||
|
middleware: (req: Request, res: Response, next: NextFunction) => void,
|
||||||
|
req: Partial<Request>,
|
||||||
|
) {
|
||||||
|
let statusCode: number | undefined;
|
||||||
|
let payload: unknown;
|
||||||
|
const res = {
|
||||||
|
status(code: number) {
|
||||||
|
statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(data: unknown) {
|
||||||
|
payload = data;
|
||||||
|
},
|
||||||
|
} as Response;
|
||||||
|
|
||||||
|
let nextCalled = false;
|
||||||
|
middleware(req as Request, res, () => {
|
||||||
|
nextCalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nextCalled, statusCode, payload, req: req as Request };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { z, type ZodType } from 'zod';
|
||||||
|
|
||||||
|
type Target = 'body' | 'params';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an Express middleware that safe-parses req[target] against `schema`.
|
||||||
|
* - Uses safeParse: no throw/catch on the hot invalid-input path.
|
||||||
|
* - On success, replaces req[target] with the parsed value (defaults filled,
|
||||||
|
* unknown keys stripped, .trim()/.transform() applied) and calls next().
|
||||||
|
* - On failure, responds 422 with { errors: [...] }.
|
||||||
|
*/
|
||||||
|
function validate<T extends ZodType>(target: Target, schema: T) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const result = schema.safeParse(req[target]);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues.map((issue) => ({
|
||||||
|
location: target,
|
||||||
|
path: issue.path.join('.'),
|
||||||
|
message: issue.message,
|
||||||
|
}));
|
||||||
|
res.status(422).json({ errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req[target] = result.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateBody = <T extends ZodType>(schema: T) => validate('body', schema);
|
||||||
|
export const validateParams = <T extends ZodType>(schema: T) => validate('params', schema);
|
||||||
|
|
||||||
|
/** Direct replacement for the old paramsWithId */
|
||||||
|
export const idParamSchema = z.object({ id: z.string().trim().min(1) });
|
||||||
|
export const validateIdParam = validateParams(idParamSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct replacement for requestValidationFunctionWithFile — unrelated to Zod (it's a
|
||||||
|
* check on multer's req.file, not on body/params shape), kept as its own middleware.
|
||||||
|
*/
|
||||||
|
export function requireUploadedFile(req: Request & { file?: unknown }, res: Response, next: NextFunction) {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(422).json({ errors: 'File not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -153,41 +153,6 @@ describe('mcp.service', () => {
|
|||||||
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
|
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits insert anchors when creating an entry without a position', async () => {
|
|
||||||
await createEntryForMcp({
|
|
||||||
type: SupportedEntry.Milestone,
|
|
||||||
title: 'End marker',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(addEntryMock).toHaveBeenCalledWith(
|
|
||||||
'loaded-rundown',
|
|
||||||
expect.objectContaining({ type: SupportedEntry.Milestone, title: 'End marker' }),
|
|
||||||
);
|
|
||||||
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('after');
|
|
||||||
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('before');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses before true for the first batch entry and chains the rest', async () => {
|
|
||||||
await batchCreateEntriesForMcp({
|
|
||||||
before: true,
|
|
||||||
entries: [
|
|
||||||
{ type: SupportedEntry.Event, title: 'First' },
|
|
||||||
{ type: SupportedEntry.Event, title: 'Second' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'loaded-rundown',
|
|
||||||
expect.objectContaining({ type: SupportedEntry.Event, title: 'First', before: true }),
|
|
||||||
);
|
|
||||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
'loaded-rundown',
|
|
||||||
expect.objectContaining({ type: SupportedEntry.Event, title: 'Second', after: 'entry-1' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects nested groups before creating entries', async () => {
|
it('rejects nested groups before creating entries', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
batchCreateEntriesForMcp({
|
batchCreateEntriesForMcp({
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
|
||||||
|
editCurrentProjectData: vi.fn(),
|
||||||
|
getProjectData: vi.fn(() => ({ title: 'Test project' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||||
|
getProjectCustomFields: vi.fn(() => ({})),
|
||||||
|
getRundownMetadata: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||||
|
createNewRundown: vi.fn(),
|
||||||
|
deleteRundown: vi.fn(),
|
||||||
|
duplicateExistingRundown: vi.fn(),
|
||||||
|
loadRundown: vi.fn(),
|
||||||
|
renameRundown: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||||
|
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../models/dataModel.js', () => ({
|
||||||
|
makeNewProject: vi.fn(() => ({ project: {} })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/project-service/ProjectService.js', () => ({
|
||||||
|
createProjectWithPatch: vi.fn(),
|
||||||
|
deleteProjectFile: vi.fn(),
|
||||||
|
duplicateProjectFile: vi.fn(),
|
||||||
|
getProjectList: vi.fn(),
|
||||||
|
loadProjectFile: vi.fn(),
|
||||||
|
renameProjectFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../stores/runtimeState.js', () => ({
|
||||||
|
getState: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../mcp.service.js', () => ({
|
||||||
|
batchCreateEntriesForMcp: vi.fn(),
|
||||||
|
batchUpdateEntriesForMcp: vi.fn(),
|
||||||
|
createCustomFieldForMcp: vi.fn(),
|
||||||
|
createEntryForMcp: vi.fn(),
|
||||||
|
deleteCustomFieldForMcp: vi.fn(),
|
||||||
|
deleteEntriesForMcp: vi.fn(),
|
||||||
|
findEntry: vi.fn(),
|
||||||
|
getRundownById: vi.fn(() => ({ id: 'r1', order: [], entries: {} })),
|
||||||
|
groupEntriesForMcp: vi.fn(),
|
||||||
|
reorderEntryForMcp: vi.fn(),
|
||||||
|
toRundownList: vi.fn(),
|
||||||
|
ungroupEntryForMcp: vi.fn(),
|
||||||
|
updateCustomFieldForMcp: vi.fn(),
|
||||||
|
updateEntryForMcp: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { TOOL_DEFINITIONS, handleToolCall } = await import('../mcp.tools.js');
|
||||||
|
|
||||||
|
describe('MCP tool schema generation', () => {
|
||||||
|
it('generates a well-formed JSON Schema inputSchema for every tool', () => {
|
||||||
|
expect(TOOL_DEFINITIONS.length).toBeGreaterThan(0);
|
||||||
|
for (const tool of TOOL_DEFINITIONS) {
|
||||||
|
expect(tool.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
expect(typeof tool.inputSchema.properties).toBe('object');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP tool-call argument validation', () => {
|
||||||
|
it('rejects a required field missing entirely (previously silently miscast)', async () => {
|
||||||
|
const result = await handleToolCall('ontime_create_rundown', {});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a field with the wrong primitive type', async () => {
|
||||||
|
const result = await handleToolCall('ontime_reorder_entry', {
|
||||||
|
entryId: 'a',
|
||||||
|
destinationId: 'b',
|
||||||
|
order: 'sideways', // not one of before/after/insert
|
||||||
|
});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown enum value on a nested field', async () => {
|
||||||
|
const result = await handleToolCall('ontime_create_entry', {
|
||||||
|
type: 'not-a-real-type',
|
||||||
|
});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a minimal valid payload for a tool with no required fields', async () => {
|
||||||
|
const result = await handleToolCall('ontime_get_rundown', {});
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unknown tool names distinctly from validation failures', async () => {
|
||||||
|
const result = await handleToolCall('ontime_does_not_exist', {});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.content[0]).toMatchObject({ type: 'text' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,10 +71,10 @@ Read the ontime://schema resource if you need a data model reference.
|
|||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and avoid duplicating existing entries.
|
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
|
||||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||||
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
||||||
5. Omit \`after\` and \`before\` on the batch call to append the new entries to the end of the rundown. Use \`after: true\` for an explicit append request, or \`before: true\` for an explicit prepend request.
|
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
|
||||||
|
|
||||||
Entry type guidance:
|
Entry type guidance:
|
||||||
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent-facing Ontime MCP documentation.
|
* Agent-facing Ontime MCP documentation.
|
||||||
*
|
*
|
||||||
@@ -9,70 +12,74 @@
|
|||||||
* Keep this file concise and update it when MCP-exposed fields change.
|
* Keep this file concise and update it when MCP-exposed fields change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---- Shared event field JSON schemas ----
|
// ---- Shared event field schemas ----
|
||||||
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
|
// Zod shape fragments, spread into z.object({...}) calls in mcp.tools.schema.ts.
|
||||||
|
// Field descriptions carry through into the generated inputSchema (z.toJSONSchema) and
|
||||||
|
// are what the MCP client/LLM actually reads — keep them in sync with reality.
|
||||||
|
|
||||||
export const EVENT_TIMER_FIELDS = {
|
export const EVENT_TIMER_FIELDS = {
|
||||||
timerType: {
|
timerType: z
|
||||||
type: 'string',
|
.enum(TimerType)
|
||||||
enum: ['count-down', 'count-up', 'clock', 'none'],
|
.optional()
|
||||||
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
|
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
|
||||||
},
|
endAction: z
|
||||||
endAction: {
|
.enum(EndAction)
|
||||||
type: 'string',
|
.optional()
|
||||||
enum: ['none', 'load-next', 'play-next'],
|
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
|
||||||
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
|
linkStart: z
|
||||||
},
|
.boolean()
|
||||||
linkStart: {
|
.optional()
|
||||||
type: 'boolean',
|
.describe(
|
||||||
description:
|
|
||||||
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
|
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
|
||||||
},
|
),
|
||||||
countToEnd: {
|
countToEnd: z
|
||||||
type: 'boolean',
|
.boolean()
|
||||||
description:
|
.optional()
|
||||||
|
.describe(
|
||||||
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
|
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
|
||||||
},
|
),
|
||||||
timeStrategy: {
|
timeStrategy: z
|
||||||
type: 'string',
|
.enum(TimeStrategy)
|
||||||
enum: ['lock-duration', 'lock-end'],
|
.optional()
|
||||||
description:
|
.describe(
|
||||||
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
||||||
},
|
),
|
||||||
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
|
timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'),
|
||||||
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
|
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const EVENT_WRITABLE_FIELDS = {
|
export const EVENT_WRITABLE_FIELDS = {
|
||||||
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
|
cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'),
|
||||||
title: { type: 'string', description: 'Event title shown in the rundown and views' },
|
title: z.string().optional().describe('Event title shown in the rundown and views'),
|
||||||
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
note: z.string().optional().describe('Free-text note for production notes or references'),
|
||||||
colour: {
|
colour: z
|
||||||
type: 'string',
|
.string()
|
||||||
description:
|
.optional()
|
||||||
|
.describe(
|
||||||
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
|
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
|
||||||
},
|
),
|
||||||
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
|
||||||
flag: {
|
flag: z
|
||||||
type: 'boolean',
|
.boolean()
|
||||||
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
|
.optional()
|
||||||
},
|
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
|
||||||
custom: {
|
custom: z
|
||||||
type: 'object',
|
.record(z.string(), z.string())
|
||||||
additionalProperties: { type: 'string' },
|
.optional()
|
||||||
description:
|
.describe(
|
||||||
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
|
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
|
||||||
},
|
),
|
||||||
...EVENT_TIMER_FIELDS,
|
...EVENT_TIMER_FIELDS,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const RUNDOWN_TARGET_FIELD = {
|
export const RUNDOWN_TARGET_FIELD = {
|
||||||
rundownId: {
|
rundownId: z
|
||||||
type: 'string',
|
.string()
|
||||||
description:
|
.optional()
|
||||||
|
.describe(
|
||||||
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
|
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||||
},
|
),
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
// ---- Agent-readable schema document ----
|
// ---- Agent-readable schema document ----
|
||||||
// Served at ontime://schema. Agents read this once per session to orient themselves
|
// Served at ontime://schema. Agents read this once per session to orient themselves
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
EntryId,
|
EntryId,
|
||||||
EventPostPayload,
|
EventPostPayload,
|
||||||
InsertOptions,
|
|
||||||
OntimeDelay,
|
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
|
||||||
OntimeGroup,
|
OntimeGroup,
|
||||||
OntimeMilestone,
|
|
||||||
PatchWithId,
|
PatchWithId,
|
||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
Rundown,
|
Rundown,
|
||||||
@@ -29,45 +25,25 @@ import {
|
|||||||
} from '../api-data/rundown/rundown.service.js';
|
} from '../api-data/rundown/rundown.service.js';
|
||||||
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
|
// *Args types are now derived from the Zod schemas in mcp.tools.schema.ts — that file is
|
||||||
export type EventFieldArgs = Partial<
|
// the single source of truth for MCP tool input shape, validation, and typing.
|
||||||
Pick<
|
import type {
|
||||||
OntimeEvent,
|
BatchCreateEntriesArgs,
|
||||||
| 'cue'
|
BatchCreateEntryArgs,
|
||||||
| 'title'
|
BatchUpdateEntriesArgs,
|
||||||
| 'note'
|
CreateCustomFieldArgs,
|
||||||
| 'colour'
|
CreateEntryArgs,
|
||||||
| 'skip'
|
DeleteCustomFieldArgs,
|
||||||
| 'flag'
|
DeleteEntriesArgs,
|
||||||
| 'custom'
|
EntryFieldArgs,
|
||||||
| 'timerType'
|
GetEntryArgs,
|
||||||
| 'endAction'
|
GroupEntriesArgs,
|
||||||
| 'linkStart'
|
ReorderEntryArgs,
|
||||||
| 'countToEnd'
|
TargetRundownArgs,
|
||||||
| 'timeStrategy'
|
UngroupEntryArgs,
|
||||||
| 'timeWarning'
|
UpdateCustomFieldArgs,
|
||||||
| 'timeDanger'
|
UpdateEntryArgs,
|
||||||
| 'timeStart'
|
} from './mcp.tools.schema.js';
|
||||||
| 'timeEnd'
|
|
||||||
| 'duration'
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
|
||||||
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
|
||||||
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
|
|
||||||
|
|
||||||
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
|
||||||
export type TargetRundownArgs = { rundownId?: string };
|
|
||||||
export type CreateEntryArgs = EntryFieldArgs & InsertOptions & TargetRundownArgs & { type?: `${SupportedEntry}` };
|
|
||||||
export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEntryArgs[] };
|
|
||||||
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
|
||||||
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
|
||||||
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
|
||||||
type BatchCreateEntriesArgs = TargetRundownArgs & {
|
|
||||||
entries: BatchCreateEntryArgs[];
|
|
||||||
after?: EntryId | true;
|
|
||||||
before?: EntryId | true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||||
return args.rundownId ?? getCurrentRundownId();
|
return args.rundownId ?? getCurrentRundownId();
|
||||||
@@ -83,7 +59,7 @@ export function getRundownById(rundownId?: string): Readonly<Rundown> {
|
|||||||
return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId);
|
return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined {
|
export function findEntry(args: GetEntryArgs): OntimeEntry | undefined {
|
||||||
const rundown = getRundownById(args.rundownId);
|
const rundown = getRundownById(args.rundownId);
|
||||||
if (args.id) {
|
if (args.id) {
|
||||||
return rundown.entries[args.id];
|
return rundown.entries[args.id];
|
||||||
@@ -138,24 +114,20 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
|
|||||||
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
||||||
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
||||||
const { type = SupportedEntry.Event, after, before } = args;
|
const { type = SupportedEntry.Event, after, before } = args;
|
||||||
const insertOptions = {
|
|
||||||
...(after !== undefined ? { after } : {}),
|
|
||||||
...(before !== undefined ? { before } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SupportedEntry.Delay:
|
case SupportedEntry.Delay:
|
||||||
return { type: SupportedEntry.Delay, duration: args.duration, ...insertOptions };
|
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
|
||||||
case SupportedEntry.Milestone: {
|
case SupportedEntry.Milestone: {
|
||||||
const { cue, title, note, colour, custom } = args;
|
const { cue, title, note, colour, custom } = args;
|
||||||
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, ...insertOptions };
|
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
|
||||||
}
|
}
|
||||||
case SupportedEntry.Group:
|
case SupportedEntry.Group:
|
||||||
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
||||||
return { type: SupportedEntry.Group, title: args.title, ...insertOptions };
|
return { type: SupportedEntry.Group, title: args.title, after, before };
|
||||||
case SupportedEntry.Event: {
|
case SupportedEntry.Event: {
|
||||||
const { type: _type, rundownId: _rundownId, after: _after, before: _before, ...eventFields } = args;
|
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
|
||||||
return { type: SupportedEntry.Event, ...eventFields, ...insertOptions };
|
return { type: SupportedEntry.Event, ...eventFields };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid entry type: ${String(type)}`);
|
throw new Error(`Invalid entry type: ${String(type)}`);
|
||||||
@@ -203,15 +175,13 @@ export async function updateEntryForMcp(args: UpdateEntryArgs) {
|
|||||||
return { target: getTargetMeta(rundownId), entry };
|
return { target: getTargetMeta(rundownId), entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) {
|
export async function deleteEntriesForMcp(args: DeleteEntriesArgs) {
|
||||||
const rundownId = resolveTargetRundownId(args);
|
const rundownId = resolveTargetRundownId(args);
|
||||||
const rundown = await deleteEntries(rundownId, args.ids);
|
const rundown = await deleteEntries(rundownId, args.ids);
|
||||||
return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order };
|
return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderEntryForMcp(
|
export async function reorderEntryForMcp(args: ReorderEntryArgs) {
|
||||||
args: TargetRundownArgs & { entryId: EntryId; destinationId: EntryId; order: 'before' | 'after' | 'insert' },
|
|
||||||
) {
|
|
||||||
const rundownId = resolveTargetRundownId(args);
|
const rundownId = resolveTargetRundownId(args);
|
||||||
const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order);
|
const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order);
|
||||||
return { target: getTargetMeta(rundownId), order: rundown.order };
|
return { target: getTargetMeta(rundownId), order: rundown.order };
|
||||||
@@ -269,24 +239,17 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
|
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
|
||||||
const { entries = [], after, before } = args;
|
const { entries = [], after } = args;
|
||||||
validateBatchCreateEntries(entries);
|
validateBatchCreateEntries(entries);
|
||||||
const allEntries = flattenBatchCreateEntries(entries);
|
const allEntries = flattenBatchCreateEntries(entries);
|
||||||
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
||||||
const rundownId = resolveTargetRundownId(args);
|
const rundownId = resolveTargetRundownId(args);
|
||||||
let previousId: EntryId | undefined;
|
let previousId = after;
|
||||||
const created: OntimeEntry[] = [];
|
const created: OntimeEntry[] = [];
|
||||||
|
|
||||||
for (const entryArgs of entries) {
|
for (const entryArgs of entries) {
|
||||||
const firstInsertOptions =
|
|
||||||
created.length === 0
|
|
||||||
? {
|
|
||||||
...(after !== undefined ? { after } : {}),
|
|
||||||
...(before !== undefined ? { before } : {}),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
||||||
const entry = await createBatchEntry(rundownId, entryArgs, previousId, undefined, firstInsertOptions);
|
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
|
||||||
created.push(...entry.created);
|
created.push(...entry.created);
|
||||||
previousId = entry.entry.id;
|
previousId = entry.entry.id;
|
||||||
}
|
}
|
||||||
@@ -317,7 +280,6 @@ async function createBatchEntry(
|
|||||||
entryArgs: BatchCreateEntryArgs,
|
entryArgs: BatchCreateEntryArgs,
|
||||||
previousId?: EntryId,
|
previousId?: EntryId,
|
||||||
parentId?: EntryId,
|
parentId?: EntryId,
|
||||||
firstInsertOptions?: InsertOptions,
|
|
||||||
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
||||||
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
||||||
throw new Error('Cannot create a group inside another group.');
|
throw new Error('Cannot create a group inside another group.');
|
||||||
@@ -326,9 +288,8 @@ async function createBatchEntry(
|
|||||||
const { children: _children, ...createArgs } = entryArgs;
|
const { children: _children, ...createArgs } = entryArgs;
|
||||||
const payload = toEntryPayload(createArgs);
|
const payload = toEntryPayload(createArgs);
|
||||||
const insertOptions = {
|
const insertOptions = {
|
||||||
...(previousId !== undefined ? { after: previousId } : {}),
|
...(previousId ? { after: previousId } : {}),
|
||||||
...(parentId !== undefined ? { parent: parentId } : {}),
|
...(parentId ? { parent: parentId } : {}),
|
||||||
...(previousId === undefined ? firstInsertOptions : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
||||||
@@ -352,14 +313,14 @@ async function createBatchEntry(
|
|||||||
return { entry, created };
|
return { entry, created };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) {
|
export async function batchUpdateEntriesForMcp(args: BatchUpdateEntriesArgs) {
|
||||||
assertKnownCustomFields(args.data.custom);
|
assertKnownCustomFields(args.data.custom);
|
||||||
const rundownId = resolveTargetRundownId(args);
|
const rundownId = resolveTargetRundownId(args);
|
||||||
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
||||||
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
|
export async function createCustomFieldForMcp(args: CreateCustomFieldArgs) {
|
||||||
const label = args.label?.trim();
|
const label = args.label?.trim();
|
||||||
// same constraint the HTTP route enforces in customFields.validation.ts
|
// same constraint the HTTP route enforces in customFields.validation.ts
|
||||||
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
|
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
|
||||||
@@ -380,13 +341,13 @@ export async function createCustomFieldForMcp(args: { label: string; type: 'text
|
|||||||
return { key, customFields: updated };
|
return { key, customFields: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
export async function updateCustomFieldForMcp(args: UpdateCustomFieldArgs) {
|
||||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
|
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
|
||||||
return { customFields: updated };
|
return { customFields: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCustomFieldForMcp(args: { key: string }) {
|
export async function deleteCustomFieldForMcp(args: DeleteCustomFieldArgs) {
|
||||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
const updated = await deleteCustomField(args.key, projectRundowns);
|
const updated = await deleteCustomField(args.key, projectRundowns);
|
||||||
return { customFields: updated };
|
return { customFields: updated };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user