Compare commits

...

16 Commits

Author SHA1 Message Date
Claude b1848d2982 refactor(settings): unify empty states, move entity forms to modals, add sidebar search
The settings UI had accumulated three inconsistencies. This addresses all
three without renaming any group or breaking `?settings=` deep links.

Empty states
- Add `Panel.EmptyState` / `Panel.TableEmpty` (title + description + action),
  promoting the treatment previously local to the spreadsheet PreviewTable.
- Add missing empty states to Custom fields, Projects, Rundowns and both
  Client tables, which previously rendered bare column headers.
- Convert the remaining `TableEmpty` call sites off the generic "No data yet".
- Rename `.empty` to `.loaderBox` in ProjectPanel, where it meant a loader box
  rather than an empty state.

Entity forms in modals
- Add `useEntityModal`, replacing six different open/close mechanisms.
- Move the multi-field create/edit forms into `Modal`: automations (wide),
  triggers, URL presets, custom fields, custom views, new rundown and project
  merge. Single-field row renames stay inline.
- Create and edit now share one surface instead of rendering above the table
  and in-place respectively, and the list is no longer interactive underneath.
- Remove the duplicate project create form; the "New" button now routes to the
  existing QuickStart modal.

Sidebar
- Add a search box with per-section keywords, so sections are reachable by the
  term users have in mind (osc, alias, google sheet, pin...).
- Fix duplicated `manage__sheets` id, which highlighted two entries at once.

Also
- Close button no longer floats over content; drop the 300px padding hack.
- Tables scroll with the panel instead of owning a nested scroll area.
- Derive divider/table/list padding from a card padding custom property.
- Fix an unreachable branch that stopped duplicate custom field labels from
  being rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP4knuna8kLe79NAuNDhUJ
2026-07-26 06:25:20 +00:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
Alex Christoffer Rasmussen 2a890cf2b3 fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times

* fix: include add time in overtime when countToEnd

* fix: ui and server use same calculation for expected end
2026-07-25 11:22:24 +02:00
Carlos Valente a8c611911d refactor(rundown): new entries are appended to rundown 2026-07-22 07:17:39 +02:00
Carlos Valente 8363f06a5c chore(rundown): improve documentation of insert logic 2026-07-22 07:17:39 +02:00
will@t1d.dev 5f040092cb fix: timer-legacy view not served in desktop distributions
The server serves html/timer-legacy.html and html/login.html from disk
at runtime, relative to the bundled server. The Docker image copies
these files but the electron packaging did not, so the view 404ed in
all desktop distributions.

Additionally, AppImages mount at /tmp/.mount_*, a hidden directory.
Express sendFile refuses paths containing dot-segments by default
(returns 404 without touching disk), so the view failed on Linux even
with the file packaged. Allow dotfiles for this route; the request
path is fixed so no user input is affected.
2026-07-19 10:02:45 +02:00
Carlos Valente 347c748dd9 feat(rundown): add shortcut to jump to current element 2026-07-18 17:03:02 +02:00
Carlos Valente f234a1f892 fix(rundown): prevent skipped events from influencing day calculations 2026-07-18 16:52:36 +02:00
Carlos Valente 0c5e87b7e7 fix(ui): prevent warning indicator from overflowing container 2026-07-18 16:52:36 +02:00
Carlos Valente 64082ceac0 refactor(rundown): improve visibility of count to end 2026-07-18 16:45:55 +02:00
Carlos Valente f598e8dab1 refactor(time control): improve handling of overtime and count to end 2026-07-18 16:45:55 +02:00
Carlos Valente 0c84a3ff9e fix: delays account for midnight 2026-07-18 16:45:55 +02:00
Carlos Valente c8760b5e9c bump version to 4.11.0 2026-07-18 15:24:25 +02:00
Claude 34360a5b8f feat(import): add merge strategy and new-rundown destination to spreadsheet import
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-17 22:26:19 +02:00
Claude 5e6debcce0 fix(views): center timer digits with flexbox for embedded browser compatibility
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).

Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.

Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.

Fixes #2126

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY
2026-07-17 20:59:49 +02:00
106 changed files with 3158 additions and 903 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.10.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.10.0",
"version": "4.11.0",
"private": true,
"type": "module",
"dependencies": {
+10
View File
@@ -6,6 +6,7 @@ import {
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
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 ======================
// #region operations on rundown entries ==========================
@@ -20,7 +20,7 @@
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
max-width: min(1800px, 98vw);
height: 88vh;
max-height: 88vh;
display: flex;
+13 -7
View File
@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
MaybeString,
OntimeDelay,
OntimeEntry,
@@ -60,9 +59,15 @@ export type EventOptions = Partial<{
before: MaybeString;
// options of entries of type OntimeEvent
linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString;
}>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
*/
@@ -120,7 +125,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
@@ -149,8 +154,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
);
queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -193,7 +199,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised');
}
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
@@ -205,8 +211,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
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];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -284,7 +290,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId, options?: InsertOptions) => {
async (entryId: EntryId, options?: ClientInsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
@@ -1057,7 +1063,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
+11
View File
@@ -139,6 +139,17 @@ export const setEventPlayback = {
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) => ({
...state.timer,
}));
@@ -0,0 +1,25 @@
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('');
});
});
+9 -4
View File
@@ -3,7 +3,9 @@ import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -29,6 +31,11 @@ export function nowInMillis(): number {
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
* @return {string|null} A format string like "hh:mm:ss a" or null
@@ -180,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
expectedEnd,
plannedEnd,
};
}
@@ -38,6 +38,18 @@ 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) {
return file?.name.endsWith('.xlsx');
}
@@ -1,10 +1,3 @@
.corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper {
display: flex;
flex-direction: column;
@@ -14,9 +7,17 @@
position: relative;
}
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
padding: 0 1rem 0.5rem;
}
.content {
margin: 1rem;
margin: 0 1rem 1rem;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
}
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}>
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -1,3 +1,13 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs,
ul {
list-style: none;
@@ -6,12 +16,33 @@ ul {
}
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin-right: 1rem;
}
.searchIcon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.searchInput {
padding-left: 2rem;
padding-right: 2rem;
}
.searchClear {
position: absolute;
right: 0.25rem;
}
.primary,
@@ -1,9 +1,14 @@
import { Fragment } from 'react';
import { useHotkeys } from '@mantine/hooks';
import { Fragment, KeyboardEvent, useMemo, useRef, useState } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import * as Panel from '../panel-utils/PanelUtils';
import { filterSettingsOptions, SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss';
@@ -16,23 +21,91 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
/** returns the destination a search result should navigate to */
function getFirstResultId(results: SettingsOption[]): SettingsOptionId | null {
const firstGroup = results[0];
if (!firstGroup) {
return null;
}
return (firstGroup.secondary?.[0]?.id ?? firstGroup.id) as SettingsOptionId;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const searchRef = useRef<HTMLInputElement>(null);
useHotkeys([['mod + f', () => searchRef.current?.focus()]]);
const results = useMemo(() => filterSettingsOptions(options, query), [options, query]);
const isSearching = query.trim().length > 0;
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// do not let the settings panel close while the user is clearing a search
event.stopPropagation();
setQuery('');
return;
}
if (event.key === 'Enter') {
const target = getFirstResultId(results);
if (target) {
setLocation(target);
setQuery('');
}
}
};
return (
<ul className={style.tabs}>
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
<div className={style.container}>
<div className={style.search}>
<IoSearch className={style.searchIcon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder='Search settings'
aria-label='Search settings'
className={style.searchInput}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.searchClear}
onClick={() => {
setQuery('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
{results.length === 0 ? (
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
) : (
<ul className={style.tabs}>
{results.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
);
}
@@ -1,24 +1,26 @@
$inner-padding: 1rem;
$card-padding: 2rem;
.header {
font-size: 2rem;
font-size: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.5rem;
font-size: 1.125rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1.375rem;
padding: 0 2rem;
font-size: 1rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
font-weight: 600;
display: flex;
align-items: center;
@@ -51,8 +53,10 @@ $inner-padding: 1rem;
}
.card {
--panel-card-padding: #{$card-padding};
position: relative;
padding: 2rem;
padding: var(--panel-card-padding);
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
@@ -76,10 +80,10 @@ $inner-padding: 1rem;
color: $error-red;
}
// tables scroll with the panel rather than owning a nested scroll area,
// which keeps the sticky table head anchored to the panel viewport
.pad {
padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
padding: 0 var(--panel-card-padding, #{$card-padding});
}
.table {
@@ -120,7 +124,7 @@ $inner-padding: 1rem;
}
.listGroup {
padding: 0 2rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -163,7 +167,7 @@ $inner-padding: 1rem;
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem -2rem;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
}
.overlay {
@@ -188,19 +192,35 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite;
}
.empty {
background-color: $black-10;
.emptyState {
padding: 3rem 1.5rem;
text-align: center;
color: $muted-gray;
}
td {
padding-block: 5rem;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
button {
margin-top: 1rem;
margin-inline: auto;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
}
.inlineElements {
@@ -239,14 +259,3 @@ $inner-padding: 1rem;
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,7 +1,5 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss';
@@ -62,16 +60,29 @@ export function Table({ className, children }: { className?: string; children: R
);
}
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<tr className={style.empty}>
<div className={style.emptyState}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{title}</div>
{description && <div className={style.emptyBody}>{description}</div>}
{action && <div className={style.emptyAction}>{action}</div>}
</div>
</div>
);
}
export function TableEmpty(props: EmptyStateProps) {
return (
<tr>
<td colSpan={99}>
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
<EmptyState {...props} />
</td>
</tr>
);
@@ -0,0 +1,29 @@
import { useCallback, useState } from 'react';
interface EntityModalState<T> {
isOpen: boolean;
entity: T | null;
}
/**
* Drives a create / edit modal for a list of entities.
* Opening with an entity means edit, opening without means create.
*
* Consumers mount the modal only while `isOpen` is true, which keeps the
* form state of the underlying react-hook-form fresh on every open.
*/
export function useEntityModal<T>() {
const [state, setState] = useState<EntityModalState<T>>({ isOpen: false, entity: null });
const openCreate = useCallback(() => setState({ isOpen: true, entity: null }), []);
const openEdit = useCallback((entity: T) => setState({ isOpen: true, entity }), []);
const close = useCallback(() => setState({ isOpen: false, entity: null }), []);
return {
isOpen: state.isOpen,
entity: state.entity,
openCreate,
openEdit,
close,
};
}
@@ -1,6 +1,14 @@
.outerColumn {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 2rem;
font-size: calc(1rem - 1px);
color: $ui-white;
// the wide modal hands us a fixed height area, we own the scroll
height: 100%;
overflow-y: auto;
padding-block: 0.5rem;
h3 {
font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils';
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -184,15 +185,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Indent
as='form'
name='automation-form'
onSubmit={handleSubmit(onSubmit)}
className={style.outerColumn}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
const bodyElements = (
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerSection}>
<h3>Automation options</h3>
<div className={style.titleSection}>
@@ -462,14 +456,29 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</Button>
</Panel.InlineElements>
</div>
</form>
);
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
const footerElements = (
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
);
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='wide'
title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -1,4 +1,4 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { Automation, AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
@@ -10,6 +10,7 @@ import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import AutomationForm from './AutomationForm';
const automationPlaceholder: AutomationDTO = {
@@ -27,7 +28,7 @@ interface AutomationsListProps {
export default function AutomationsList(props: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const automationModal = useEntityModal<Automation>();
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
@@ -45,13 +46,12 @@ export default function AutomationsList(props: AutomationsListProps) {
return (
<Panel.Card>
{automationModal.isOpen && (
<AutomationForm automation={automationModal.entity ?? automationPlaceholder} onClose={automationModal.close} />
)}
<Panel.SubHeader>
Manage automations
<Button
type='submit'
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
<Button onClick={automationModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -65,10 +65,6 @@ export default function AutomationsList(props: AutomationsListProps) {
</Info>
)}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
@@ -82,7 +78,13 @@ export default function AutomationsList(props: AutomationsListProps) {
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
action={
<Button variant='primary' onClick={automationModal.openCreate}>
Create automation <IoAdd />
</Button>
}
/>
)}
{arrayAutomations.map((automationId) => {
@@ -102,7 +104,7 @@ export default function AutomationsList(props: AutomationsListProps) {
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
onClick={() => automationModal.openEdit(automations[automationId])}
>
<IoPencil />
</IconButton>
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
@@ -6,30 +6,21 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps {
automations: NormalisedAutomation;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
trigger: Trigger | null;
onCancel: () => void;
postSubmit: () => void;
}
export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
const {
handleSubmit,
register,
@@ -40,9 +31,9 @@ export default function TriggerForm({
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: initialTitle,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
title: trigger?.title,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
},
resetOptions: {
keepDirtyValues: true,
@@ -55,10 +46,10 @@ export default function TriggerForm({
}, [setFocus]);
const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger
if (initialId) {
// if we were passed a trigger we are editing it
if (trigger) {
try {
await editTrigger(initialId, { id: initialId, ...values });
await editTrigger(trigger.id, { id: trigger.id, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -84,20 +75,14 @@ export default function TriggerForm({
const canSubmit = isDirty && isValid;
return (
<Panel.Indent
as='form'
name='trigger-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
const bodyElements = (
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={initialTitle}
defaultValue={trigger?.title}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
@@ -127,14 +112,30 @@ export default function TriggerForm({
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
</form>
);
const footerElements = (
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
);
return (
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -8,8 +8,9 @@ import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import { checkDuplicates } from './automationUtils';
import AutomationForm from './TriggerForm';
import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
interface TriggersListProps {
@@ -20,7 +21,7 @@ interface TriggersListProps {
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const triggerModal = useEntityModal<Trigger>();
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -35,7 +36,7 @@ export default function TriggersList(props: TriggersListProps) {
};
const postSubmit = () => {
setShowForm(false);
triggerModal.close();
refetch();
};
@@ -46,9 +47,17 @@ export default function TriggersList(props: TriggersListProps) {
return (
<Panel.Card>
{triggerModal.isOpen && (
<TriggerForm
automations={automations}
trigger={triggerModal.entity}
onCancel={triggerModal.close}
postSubmit={postSubmit}
/>
)}
<Panel.SubHeader>
Manage triggers
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
<Button disabled={!canAdd} onClick={triggerModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -64,9 +73,6 @@ export default function TriggersList(props: TriggersListProps) {
You have created multiple links between the same trigger and automation which can performance issues.
</Panel.Error>
)}
{showForm && (
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
)}
<Panel.Table>
<thead>
<tr>
@@ -77,10 +83,21 @@ export default function TriggersList(props: TriggersListProps) {
</tr>
</thead>
<tbody>
{!showForm && triggers.length === 0 && (
{triggers.length === 0 && (
<Panel.TableEmpty
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
title='No triggers yet'
description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
}
action={
canAdd && (
<Button variant='primary' onClick={triggerModal.openCreate}>
Create trigger <IoAdd />
</Button>
)
}
/>
)}
{triggers.map((trigger, index) => {
@@ -88,13 +105,10 @@ export default function TriggersList(props: TriggersListProps) {
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
id={trigger.id}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => triggerModal.openEdit(trigger)}
handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/>
{deleteError && (
<tr>
@@ -1,48 +1,21 @@
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps {
automations: NormalisedAutomation;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
trigger: Trigger;
duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void;
postSubmit: () => void;
}
export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
return (
<tr data-warn={duplicate}>
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500
/>
)}
{title}
{trigger.title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[automationId]?.title}</Tag>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -66,7 +66,10 @@ export default function ReportSettings() {
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty label='Reports are generated when running through the show.' />
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => {
@@ -12,25 +12,17 @@ import Tag from '../../../../common/components/tag/Tag';
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import URLPresetForm from './composite/URLPresetForm';
type FormState = {
isOpen: boolean;
preset?: URLPreset;
};
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const presetModal = useEntityModal<URLPreset>();
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
@@ -43,9 +35,12 @@ export default function URLPresets() {
return (
<Panel.Section>
<Panel.Card>
{presetModal.isOpen && (
<URLPresetForm urlPreset={presetModal.entity ?? undefined} onClose={presetModal.close} />
)}
<Panel.SubHeader>
URL presets
<Button onClick={openNewForm}>
<Button onClick={presetModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -63,7 +58,6 @@ export default function URLPresets() {
</Panel.Section>
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table>
<thead>
@@ -76,7 +70,17 @@ export default function URLPresets() {
</tr>
</thead>
<tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.length === 0 && (
<Panel.TableEmpty
title='No URL presets yet'
description='A preset gives one of your views a short, memorable address, with all of its options baked in.'
action={
<Button variant='primary' onClick={presetModal.openCreate}>
Create preset <IoAdd />
</Button>
}
/>
)}
{data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return (
@@ -109,7 +113,7 @@ export default function URLPresets() {
<IoOpenOutline />
</IconButton>
<IconButton
onClick={() => openEditForm(preset)}
onClick={() => presetModal.openEdit(preset)}
variant='ghosted-white'
aria-label='Edit entry'
data-testid={`field__edit_${index}`}
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
@@ -28,6 +28,8 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
];
const formId = 'url-preset-form';
const defaultValues: URLPreset = {
alias: '',
target: OntimeView.Timer,
@@ -133,13 +135,8 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(setupSubmit)}
onKeyDown={(event) => preventEscape(event, onClose)}
className={style.column}
>
const bodyElements = (
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
<input hidden name='enabled' value='true' />
<div>1. Enter URL and let Ontime generate the preset options</div>
@@ -209,20 +206,34 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
/>
</div>
)}
<div>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='primary'
type='submit'
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save
</Button>
</Panel.InlineElements>
</div>
</Panel.Indent>
</form>
);
const footerElements = (
<>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='primary'
type='submit'
form={formId}
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save
</Button>
</>
);
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
@@ -10,30 +9,25 @@ import ExternalLink from '../../../../common/components/link/external-link/Exter
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm';
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const fieldModal = useEntityModal<CustomFieldEntity>();
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
const handleSubmit = async (customField: CustomField) => {
const editing = fieldModal.entity;
if (editing) {
await editCustomField(editing.key, customField);
} else {
await postCustomField(customField);
}
refetch();
fieldModal.close();
};
const handleDelete = async (key: CustomFieldKey) => {
@@ -45,12 +39,24 @@ export default function CustomFieldSettings() {
}
};
const entries = Object.entries(data);
return (
<Panel.Section>
<Panel.Card>
{fieldModal.isOpen && (
<CustomFieldForm
onSubmit={handleSubmit}
onCancel={fieldModal.close}
initialColour={fieldModal.entity?.colour}
initialLabel={fieldModal.entity?.label}
initialKey={fieldModal.entity?.key}
initialType={fieldModal.entity?.type}
/>
)}
<Panel.SubHeader>
Custom fields
<Button onClick={handleInitiateCreate}>
<Button onClick={fieldModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -66,7 +72,6 @@ export default function CustomFieldSettings() {
</Info>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
@@ -78,7 +83,18 @@ export default function CustomFieldSettings() {
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label, type }]) => {
{entries.length === 0 && (
<Panel.TableEmpty
title='No custom fields yet'
description='Custom fields add your own columns to the rundown, and can be shown in views or used in automations.'
action={
<Button variant='primary' onClick={fieldModal.openCreate}>
Create custom field <IoAdd />
</Button>
}
/>
)}
{entries.map(([key, { colour, label, type }]) => {
return (
<CustomFieldEntry
key={key}
@@ -86,7 +102,7 @@ export default function CustomFieldSettings() {
colour={colour}
label={label}
type={type}
onEdit={handleEditField}
onEdit={() => fieldModal.openEdit({ key, colour, label, type })}
onDelete={handleDelete}
/>
);
@@ -5,11 +5,14 @@ import { uploadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss';
const formId = 'custom-view-form';
interface CustomViewFormProps {
onComplete: () => void;
onClose: () => void;
@@ -52,8 +55,8 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
}
};
return (
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
const bodyElements = (
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
@@ -100,15 +103,28 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
</form>
);
const footerElements = (
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</>
);
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</Panel.InlineElements>
</Panel.Indent>
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='Upload custom view'
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -37,13 +37,14 @@ export default function CustomViews() {
return (
<Panel.Section>
<Panel.Card>
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
<Panel.SubHeader>
Custom views
<Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo
</Button>
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
<Button onClick={() => setIsUploadOpen(true)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
@@ -63,8 +64,6 @@ export default function CustomViews() {
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList
@@ -1,5 +1,6 @@
import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr>
</thead>
<tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{views.length === 0 && (
<Panel.TableEmpty
title='No custom views yet'
description='Upload a self-contained index.html to serve your own view alongside the built-in ones.'
action={
<Button variant='primary' onClick={onOpenUpload}>
Upload view <IoCloudUploadOutline />
</Button>
}
/>
)}
{views.map((view, index) => (
<CustomViewsListItem
key={view.slug}
@@ -12,6 +12,12 @@
gap: 1rem;
}
.formColumn {
display: flex;
flex-direction: column;
gap: 1rem;
}
.current {
background-color: $blue-1100 !important; // fighting zebra styles
}
@@ -3,9 +3,12 @@ import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils';
const formId = 'new-rundown-form';
type NewRundownFormState = {
title: string;
};
@@ -41,23 +44,38 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
setFocus('title');
}, [setFocus]);
return (
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
const bodyElements = (
<form id={formId} onSubmit={handleSubmit(createRundown)}>
<Panel.Section>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' />
</label>
</Panel.Section>
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</Panel.InlineElements>
</form>
);
const footerElements = (
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Indent>
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</>
);
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='Create rundown'
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -131,6 +131,23 @@ export default function ManageRundowns() {
</tr>
</thead>
<tbody>
{data?.rundowns?.length === 0 && (
<Panel.TableEmpty
title='No rundowns yet'
description='A project can hold several rundowns. Create one to start building your show.'
action={
<Button
variant='primary'
onClick={() => {
setActionError(null);
newHandlers.open();
}}
>
Create rundown <IoAdd />
</Button>
}
/>
)}
{data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton';
@@ -7,7 +6,6 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss';
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
label: string;
fieldKey: string;
type: CustomField['type'];
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onEdit: () => void;
onDelete: (key: CustomFieldKey) => Promise<void>;
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
await onEdit(fieldKey, patch);
setIsEditing(false);
};
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<CustomFieldForm
onCancel={() => setIsEditing(false)}
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
initialKey={fieldKey}
initialType={type}
/>
</td>
</tr>
);
}
return (
<tr>
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag>
</td>
<Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
@@ -8,13 +8,15 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss';
const formId = 'custom-field-form';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void;
@@ -84,8 +86,8 @@ export default function CustomFieldForm({
// if initial values are given, we can assume we are in edit mode
const isEditMode = initialKey !== undefined;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
const bodyElements = (
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
@@ -116,8 +118,8 @@ export default function CustomFieldForm({
if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
return 'Custom fields must be unique';
}
return true;
},
@@ -135,15 +137,30 @@ export default function CustomFieldForm({
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label>
</form>
);
const footerElements = (
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
);
return (
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -25,6 +25,13 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,4 +1,6 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
@@ -6,6 +8,7 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -22,7 +25,7 @@ import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -35,6 +38,7 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource =
| {
kind: 'excel';
fileName: string;
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean;
@@ -53,9 +57,11 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { importRundown } = useSpreadsheetImport();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -74,6 +80,7 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({
kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false,
@@ -97,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -119,29 +127,41 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
const handleApplyImport = async (
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(title);
return;
}
if (!currentRundown) {
throw new Error('No current rundown loaded');
}
await importRundown(
{
[currentRundown.id]: {
...preview.rundown,
id: currentRundown.id,
title: currentRundown.title,
},
},
preview.customFields,
);
handleFinished();
// override or merge into the current rundown; merge uses providedFields to know which columns
// the sheet supplied so matched events keep the rest (e.g. automations)
await applyImport({
mode,
targetRundownId: currentRundown.id,
rundown: preview.rundown,
customFields: preview.customFields,
providedFields,
});
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -208,6 +228,13 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel';
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 (
<Panel.Section>
@@ -267,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Reset flow
</Button>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div>
)}
{isGSheetFlow && (
@@ -287,6 +323,7 @@ export default function SourcesPanel() {
bodyElements={
<SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata}
@@ -0,0 +1,55 @@
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>
);
}
@@ -33,6 +33,11 @@
white-space: nowrap;
}
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger {
justify-content: center;
white-space: nowrap;
@@ -87,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -1,10 +1,20 @@
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
import { useMemo } from 'react';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
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 * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm';
@@ -13,17 +23,46 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onCancel: () => 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({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -49,11 +88,16 @@ export default function SheetImportEditor({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit,
handleExportSubmit,
handleApply,
} = useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -62,6 +106,19 @@ export default function SheetImportEditor({
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 (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -75,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
{toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -93,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div>
<div className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -107,33 +179,63 @@ export default function SheetImportEditor({
</div>
{displayError && <Panel.Error>{displayError}</Panel.Error>}
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
{importMode === 'merge' && !isIdColumnMapped(values) && (
<Panel.Description tone='warning'>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export
your rundown to a spreadsheet first to keep its IDs.
</Panel.Description>
)}
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
<Panel.InlineElements wrap='wrap'>
<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
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<Button
variant='primary'
onClick={handleApply}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
>
Apply import
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
</Button>
)}
<Button
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<ApplyImportButton
preview={state.preview}
mode={importMode}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
</Panel.InlineElements>
</Panel.Section>
);
@@ -1,15 +1,21 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import {
builtInFieldDefs,
convertToImportMap,
createDefaultFormValues,
defaultImportMode,
getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => {
@@ -128,3 +134,64 @@ 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');
});
});
@@ -1,3 +1,4 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -49,6 +50,16 @@ 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 {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName
@@ -81,6 +92,24 @@ 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 {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => {
@@ -130,12 +159,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey);
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
const persistedData = localStorage.getItem(storageKey);
if (!persistedData) {
return { values: createDefaultFormValues(), isPersisted: false };
}
const parsed: unknown = JSON.parse(raw);
const parsed: unknown = JSON.parse(persistedData);
if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true };
}
@@ -150,6 +179,32 @@ 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.
*/
@@ -1,27 +1,8 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
text-align: center;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
}
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +15,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
min-width: 8rem;
vertical-align: top;
max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
tr:nth-child(even) {
background-color: $white-1;
tbody tr {
--entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
left: 3.25rem;
width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
}
@@ -1,7 +1,12 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import * as Panel from '../../../../../panel-utils/PanelUtils';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -9,14 +14,85 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[];
}
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -24,33 +100,51 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
}
return (
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
</div>
<Panel.EmptyState
title={emptyTitle}
description={emptyContent}
action={
needsPreviewRefresh && (
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
Refresh preview
</Button>
)
}
/>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +153,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</td>
{displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
</tr>
);
})}
@@ -1,7 +1,13 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -11,8 +17,11 @@ import {
builtInFieldDefs,
convertToImportMap,
getImportWarnings,
getPersistedImportMode,
getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields,
persistImportMode,
persistImportState,
} from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -24,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -32,12 +41,14 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -49,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null };
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
}
return { ...state, loading: '', error: action.error };
}
@@ -100,16 +111,23 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
}
export function useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -170,6 +188,8 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -182,6 +202,12 @@ export function useSheetImportForm({
dispatch({ type: '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.
useEffect(() => {
if (worksheetNames.length === 0) return;
@@ -198,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -241,13 +267,15 @@ export function useSheetImportForm({
try {
dispatch({ type: 'startApply' });
await onApply(state.preview);
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
await onApply(state.preview, importMode, newRundownTitle, providedFields);
persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' });
} catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) });
}
}, [getValues, onApply, sourceKey, state.preview]);
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
const handleExport = useCallback(
async (formValues: ImportFormValues) => {
@@ -272,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`;
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
}
const { flatOrder } = state.preview.rundown;
const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
})();
return {
@@ -300,6 +350,10 @@ export function useSheetImportForm({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport),
handleApply,
@@ -1,30 +1,17 @@
import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { RundownImportPayload } from 'ontime-types';
import { useCallback } from 'react';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
import { importRundownWithOptions } from '../../../../../common/api/rundown';
export default function useSpreadsheetImport() {
const queryClient = useQueryClient();
/** applies rundown and customFields to current project */
const importRundown = useCallback(
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],
);
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
// through that single path rather than racing it with an optimistic write from here
await importRundownWithOptions(payload);
}, []);
return {
importRundown,
applyImport,
};
}
@@ -68,6 +68,12 @@ export default function ClientList() {
</tr>
</thead>
<tbody>
{ontimeClients.length === 0 && (
<Panel.TableEmpty
title='No Ontime clients connected'
description='Editor, cuesheet and operator windows appear here as they connect.'
/>
)}
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
@@ -125,6 +131,12 @@ export default function ClientList() {
</tr>
</thead>
<tbody>
{otherClients.length === 0 && (
<Panel.TableEmpty
title='No other clients connected'
description='Views and integrations connected to this server are listed here.'
/>
)}
{otherClients.map(([key, client]) => {
const { name, type } = client;
@@ -1,29 +1,21 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import ProjectCreateForm from './ProjectCreateForm';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectList from './ProjectList';
export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams();
const { setLocation } = useAppSettingsNavigation();
const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('new') === 'true';
const handleToggleCreate = () => {
searchParams.set('new', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams);
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
@@ -49,11 +41,6 @@ export default function ManageProjects() {
setLoading(null);
};
const handleCloseForm = () => {
searchParams.delete('new');
setSearchParams(searchParams);
};
return (
<Panel.Section>
<input
@@ -68,21 +55,16 @@ export default function ManageProjects() {
<Panel.SubHeader>
Manage projects
<Panel.InlineElements>
<Button
onClick={handleSelectFile}
disabled={Boolean(loading) || isCreatingProject}
loading={loading === 'import'}
>
<Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
Import
</Button>
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
<Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider />
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
@@ -1,84 +0,0 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
interface ProjectCreateFromProps {
onClose: () => void;
}
type ProjectCreateFormValues = {
title?: string;
description?: string;
info?: string;
url?: string;
custom?: { title: string; value: string }[];
};
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
const [error, setError] = useState<string | null>(null);
const {
handleSubmit,
register,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
defaultValues: { title: '' },
values: { title: '' },
resetOptions: {
keepDirtyValues: true,
},
});
// set focus to first field
useEffect(() => {
setFocus('title');
}, [setFocus]);
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try {
setError(null);
const filename = values.title ?? 'untitled';
await createProject({ filename });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(handleSubmitCreate)}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.Title>
Create new project
<Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'>
Create project
</Button>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}>
<Panel.Description>Project title</Panel.Description>
<Input fluid placeholder='Your project name' {...register('title')} />
</Panel.Section>
</Panel.Indent>
);
}
@@ -40,7 +40,7 @@ export default function ProjectList() {
if (status === 'pending') {
return (
<div className={style.empty}>
<div className={style.loaderBox}>
<Panel.Loader isLoading />
</div>
);
@@ -74,6 +74,12 @@ export default function ProjectList() {
</tr>
</thead>
<tbody>
{numProjects === 0 && (
<Panel.TableEmpty
title='No projects yet'
description='Projects are the show files Ontime saves your rundown and settings into. Create one to get started.'
/>
)}
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
@@ -158,13 +158,7 @@ export default function ProjectListItem({
</>
)}
</tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
<Dialog
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
@@ -7,6 +7,7 @@ import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Modal from '../../../../common/components/modal/Modal';
import Switch from '../../../../common/components/switch/Switch';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -14,6 +15,8 @@ import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
const formId = 'project-merge-form';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
@@ -76,20 +79,8 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Partial project merge
<Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
Merge
</Button>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
const bodyElements = (
<form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
@@ -139,6 +130,30 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
Automation Settings
</label>
</Panel.Section>
</Panel.Section>
</form>
);
const footerElements = (
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' form={formId} disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
Merge
</Button>
</>
);
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='Partial project merge'
bodyElements={bodyElements}
footerElements={footerElements}
/>
);
}
@@ -55,7 +55,7 @@
margin-bottom: 1rem;
}
.empty {
.loaderBox {
height: 300px;
position: relative;
}
@@ -8,6 +8,8 @@ export type SettingsOption = {
label: string;
secondary?: Readonly<SettingsOption[]>;
highlight?: string;
/** alternative terms a user may search for when looking for this section */
keywords?: Readonly<string[]>;
};
const staticOptions = [
@@ -15,52 +17,94 @@ const staticOptions = [
id: 'settings',
label: 'Settings',
secondary: [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__mcp', label: 'MCP Server' },
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
{ id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] },
{
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone', '12 hour', '24 hour'],
},
{
id: 'settings__view',
label: 'View settings',
keywords: ['css', 'style', 'theme', 'override', 'translation', 'freeze', 'overtime'],
},
{ id: 'settings__custom-views', label: 'Custom views', keywords: ['html', 'upload', 'external', 'embed'] },
{ id: 'settings__mcp', label: 'MCP Server', keywords: ['ai', 'agent', 'model context protocol'] },
...(isDocker
? []
: [{ id: 'settings__port', label: 'Server port', keywords: ['http', 'network', 'address', 'listen'] }]),
],
},
{
id: 'project',
label: 'Project',
secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' },
{ id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] },
{
id: 'project__list',
label: 'Manage projects',
keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'],
},
],
},
{
id: 'manage',
label: 'Project settings',
secondary: [
{ id: 'manage__defaults', label: 'Rundown defaults' },
{ id: 'manage__custom', label: 'Custom fields' },
{ id: 'manage__rundowns', label: 'Manage rundowns' },
{ id: 'manage__sheets', label: 'Import spreadsheet' },
{ id: 'manage__sheets', label: 'Sync with Google Sheet' },
{
id: 'manage__defaults',
label: 'Rundown defaults',
keywords: ['duration', 'warning time', 'danger time', 'defaults'],
},
{
id: 'manage__custom',
label: 'Custom fields',
keywords: ['metadata', 'columns', 'extra data', 'image field'],
},
{
id: 'manage__rundowns',
label: 'Manage rundowns',
keywords: ['rundown', 'xlsx', 'excel', 'duplicate', 'load'],
},
{
id: 'manage__sheets',
label: 'Import spreadsheet',
keywords: ['google sheet', 'sync', 'spreadsheet', 'xlsx', 'excel', 'csv', 'export'],
},
],
},
{
id: 'automation',
label: 'Automation',
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' },
{
id: 'automation__settings',
label: 'Automation settings',
keywords: ['osc input', 'port', 'enable', 'remote control'],
},
{
id: 'automation__automations',
label: 'Manage automations',
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
},
{
id: 'automation__triggers',
label: 'Manage triggers',
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
},
],
},
{
id: 'sharing',
label: 'Sharing and reporting',
secondary: [
{ id: 'sharing__presets', label: 'URL Presets' },
{ id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
{
id: 'sharing__link',
label: 'Share link',
keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'],
},
{ id: 'sharing__report', label: 'Runtime report' },
{ id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] },
],
},
{
@@ -70,20 +114,24 @@ const staticOptions = [
{
id: 'network__log',
label: 'Event log',
keywords: ['debug', 'errors', 'console', 'export log'],
},
{
id: 'network__clients',
label: 'Manage clients',
keywords: ['redirect', 'identify', 'rename client', 'connected'],
},
],
},
{
id: 'about',
label: 'About',
keywords: ['version', 'update', 'licence', 'license', 'credits'],
},
{
id: 'shutdown',
label: 'Shutdown',
keywords: ['quit', 'exit', 'close ontime'],
},
] as const;
@@ -92,6 +140,37 @@ export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
function matchesQuery(option: SettingsOption, query: string): boolean {
if (option.label.toLowerCase().includes(query)) {
return true;
}
return Boolean(option.keywords?.some((keyword) => keyword.includes(query)));
}
/**
* Filters the settings menu against a user query.
* A group is kept if it matches itself (with all its children) or if any of its children match.
*/
export function filterSettingsOptions(options: Readonly<SettingsOption[]>, query: string): SettingsOption[] {
const normalised = query.trim().toLowerCase();
if (!normalised) {
return [...options];
}
return options.reduce<SettingsOption[]>((accumulator, option) => {
if (matchesQuery(option, normalised)) {
accumulator.push(option);
return accumulator;
}
const secondary = option.secondary?.filter((child) => matchesQuery(child, normalised));
if (secondary?.length) {
accumulator.push({ ...option, secondary });
}
return accumulator;
}, []);
}
export function useAppSettingsMenu() {
const { data } = useAppVersion();
@@ -73,6 +73,10 @@
margin-right: 0.25rem;
}
.tagOvertime {
color: $playback-over;
}
.time {
color: $section-white;
font-size: $text-body-size;
@@ -1,12 +1,13 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time';
import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
}
export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer();
'use memo';
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return (
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
<RunningStatus
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
)}
</div>
{children}
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
playback: Playback;
isStopped: boolean;
isCountToEnd: boolean;
isOvertime: boolean;
}
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
if (isStopped) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
return (
<>
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.time}>{finish}</span>
</span>
</>
+20 -1
View File
@@ -105,12 +105,29 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
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
useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
});
// DND handlers
@@ -322,7 +339,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }}
components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
Header: isEditMode
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Footer: () => (
<>
{isEditMode && (
@@ -44,6 +44,9 @@ function EventEditorEmpty() {
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to current entry'>
<Combo keys={[deviceAlt, 'L']} />
</Shortcut>
<Shortcut label='Deselect entry'>
<Combo keys={['Esc']} />
</Shortcut>
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime } from '../../../../common/utils/time';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
@@ -68,8 +68,10 @@ function EventEditorTimes({
};
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)}${formatTime(timeEnd + delay)}`
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
@@ -86,7 +88,6 @@ function EventEditorTimes({
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
countToEnd={countToEnd}
showLabels
/>
</div>
@@ -11,13 +11,17 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps {
previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString;
backgroundColor?: string;
}
export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => {
addEntry(
@@ -25,44 +29,23 @@ function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: Quic
type: SupportedEntry.Event,
parent: parentGroup,
},
{
after: previousEventId,
lastEventId: previousEventId,
},
insertionOptions,
);
};
const addDelay = () => {
addEntry(
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
};
const addMilestone = () => {
addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
};
const addGroup = () => {
if (parentGroup !== null) {
return;
}
addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Group }, insertionOptions);
};
/**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
lastEventId: id && !above ? id : undefined,
});
},
[addEntry],
@@ -19,6 +19,7 @@ interface UseRundownKeyboardOptions {
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
}
/**
@@ -40,6 +41,7 @@ export function useRundownKeyboard({
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -127,6 +129,8 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'),
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
background-color: $block-bg;
margin-block: 0.25rem;
overflow: initial;
position: relative;
display: grid;
grid-template-areas:
@@ -16,7 +17,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem 1fr 3rem;
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center;
padding-right: $block-clearance;
@@ -136,6 +137,7 @@ $skip-opacity: 0.2;
align-items: center;
gap: $block-clearance;
height: 100%;
min-width: 0;
}
.eventTimers.editMode:hover {
@@ -155,7 +157,7 @@ $skip-opacity: 0.2;
align-items: center;
justify-content: space-between;
.nextTag {
.warningMeta {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -231,6 +233,10 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning {
color: $orange-500;
}
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
countToEnd={countToEnd}
/>
</div>
<div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.nextTag}>UP NEXT</span>}
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
</div>
<EventBlockPlayback
eventId={eventId}
@@ -157,7 +157,13 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip>
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
<LuArrowDownToLine
className={cx([
style.statusIcon,
countToEnd ? style.active : style.disabled,
countToEnd && style.countToEndStatus,
])}
/>
</Tooltip>
<Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} />
@@ -1,10 +1,33 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
const timeStart = 60000; // 1 min
const delay = 60000; // 1 min
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
const result = formatDelay(timeStart, delay);
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');
});
});
@@ -1,15 +1,16 @@
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
const delayedStart = Math.max(0, timeStart + delay);
const delayedStart = normaliseWallClock(timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
@@ -4,10 +4,15 @@
.timerNote {
width: 1.25em;
flex: 0 0 1.25em;
color: $blue-500;
font-size: 1.5em;
}
.timerNotePlaceholder {
visibility: hidden;
}
.inactive {
color: $muted-gray;
}
@@ -14,7 +14,6 @@ import style from './TimeInputFlow.module.scss';
interface TimeInputFlowProps {
eventId: string;
countToEnd: boolean;
timeStart: number;
timeEnd: number;
duration: number;
@@ -22,12 +21,12 @@ interface TimeInputFlowProps {
linkStart: boolean;
delay: number;
showLabels?: boolean;
showWarnings?: boolean;
}
export default memo(TimeInputFlow);
function TimeInputFlow({
eventId,
countToEnd,
timeStart,
timeEnd,
duration,
@@ -35,6 +34,7 @@ function TimeInputFlow({
linkStart,
delay,
showLabels,
showWarnings = true,
}: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -56,10 +56,6 @@ function TimeInputFlow({
warnings.push('Over midnight');
}
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -136,11 +132,19 @@ function TimeInputFlow({
</TimeInputGroup>
</div>
{warnings.length > 0 && (
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
<IoAlertCircleOutline />
</Tooltip>
)}
{showWarnings &&
(warnings.length > 0 ? (
<Tooltip
text={warnings.join(' - ')}
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
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
@@ -1,17 +1,15 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
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
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}
interface SubscriptionStatusProps {
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
event: CountdownEvent;
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
@@ -12,7 +12,7 @@ import {
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
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
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
@@ -271,6 +271,7 @@ export function extendEventData(
offset,
mode,
});
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
return { ...event, expectedStart, endedAt, expectedEnd };
}
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
{
type: 'item',
label: 'Planning',
description: 'Edit-focused list with planning stats',
description: 'Edit focused list with planning stats',
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
},
@@ -28,9 +28,9 @@
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
+3 -3
View File
@@ -82,9 +82,9 @@
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
+8 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.10.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -125,6 +125,13 @@
"**/*",
"*{.ts}"
]
},
{
"from": "../server/src/html/",
"to": "extraResources/html/",
"filter": [
"**/*"
]
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.10.0",
"version": "4.11.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.10.0",
"version": "4.11.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -432,6 +432,40 @@ describe('processRundown()', () => {
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', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
@@ -680,17 +714,17 @@ describe('processRundown()', () => {
});
describe('rundownMutation.add()', () => {
test('adds an event an empty rundown', () => {
test('adds an event to an empty rundown', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({});
rundownMutation.add(rundown, mockEvent, null, null);
rundownMutation.add(rundown, mockEvent, null, null, null);
expect(rundown.order.length).toBe(1);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the top if no afterId is given', () => {
test('adds an event at the end if no afterId is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1'],
@@ -700,28 +734,28 @@ describe('rundownMutation.add()', () => {
},
});
rundownMutation.add(rundown, mockEvent, null, null);
rundownMutation.add(rundown, mockEvent, null, null, null);
expect(rundown.order).toStrictEqual(['mock', '1']);
expect(rundown.flatOrder).toStrictEqual(['mock', '1']);
expect(rundown.order).toStrictEqual(['1', 'mock']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock']);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the top of the group if no after is given', () => {
test('adds an event at the end of the group if parent is provided but no after', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeGroup({ id: '1' }),
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup);
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, null, null);
expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
@@ -731,12 +765,12 @@ describe('rundownMutation.add()', () => {
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeGroup({ id: '1' }),
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup);
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, '1a', null);
expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
@@ -1805,6 +1839,49 @@ describe('rundownMutation.clone()', () => {
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()', () => {
@@ -1,8 +1,18 @@
import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
import {
EndAction,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
TimeStrategy,
TimerLifeCycle,
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
deleteById,
@@ -10,6 +20,8 @@ import {
getIntegerAndFraction,
hasChanges,
makeDeepClone,
mergeRundownPreservingFields,
isLoadedPlayable,
} from '../rundown.utils.js';
describe('test event validator', () => {
@@ -287,3 +299,314 @@ describe('getIntegerAndFraction()', () => {
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);
});
});
@@ -0,0 +1,40 @@
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',
);
});
});
+41 -43
View File
@@ -439,21 +439,27 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
rundown.entries[nestedEntry.id] = nestedEntry;
}
// by default we insert after the cloned element
let atIndex = rundown.order.indexOf(entry.id) + 1;
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 position: { after: EntryId | null; before: EntryId | null } = (() => {
if (options?.after === undefined && options?.before === undefined) {
return { after: entry.id, before: null };
}
}
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
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
@@ -461,41 +467,33 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
return newGroup;
} else {
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;
})();
let parent: OntimeGroup | null = null;
const referenceEntry = referenceId ? rundown.entries[referenceId] : entry;
const parentId = referenceEntry && !isOntimeGroup(referenceEntry) ? referenceEntry.parent : null;
if (parentId === null) return null;
// trying to insert relatively to another entry, check that entries parent
const referenceId = options?.after ?? options?.before;
const maybeParent = rundown.entries[parentId];
return isOntimeGroup(maybeParent) ? maybeParent : null;
})();
/**
* if we have a positioning reference, and that reference has a parent
* we need to maintain the same parent for the cloned entry
*/
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;
}
}
const position = (() => {
// if no position is given, we add immediately after the element
if (options?.after === undefined && options?.before === undefined) {
return { after: entry.id, before: null };
}
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
// if we have resolved a parent, we add it to the cloned entry
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
if (!after) {
after = entry.id;
}
const after = getInsertAfterId(rundown, parent, options.after, options.before);
const before = options?.before === true ? after : (options.before ?? null);
return { after, before };
})();
return addToRundown(rundown, clonedEntry, after, parent);
return addToRundown(rundown, clonedEntry, parent, position.after, position.before);
}
}
@@ -279,14 +279,22 @@ function processEntry<T extends OntimeEntry>(
// 2. handle custom fields - mutates entry
sanitiseCustomFields(customFields, entry);
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
entry.dayOffset = rundownMetadata.totalDays as Day;
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
/*
* we initialise data so that is not calculated for skipped events
* consider especially the day offset, while skipped events have no
* day offset, we match it to the previous element to avoid
* pushing confusing data to the UI
*/
entry.dayOffset = (rundownMetadata.previousEvent?.dayOffset ?? 0) as Day;
entry.delay = 0;
entry.gap = 0;
entry.parent = childOfGroup;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(entry)) {
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
entry.dayOffset = rundownMetadata.totalDays as Day;
rundownMetadata.playableEventOrder.push(entry.id);
// first start is always the first event
@@ -1,7 +1,15 @@
import type { Request, Response, Router } from 'express';
import express from 'express';
import { matchedData } from 'express-validator';
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
import {
ErrorResponse,
OntimeEntry,
ProjectRundowns,
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -12,7 +20,9 @@ import {
applyDelay,
batchEditEntries,
cloneEntry,
applyImportToRundown,
createNewRundown,
createRundownFromImport,
deleteAllEntries,
deleteEntries,
deleteRundown,
@@ -36,6 +46,7 @@ import {
entryReorderValidator,
entrySwapValidator,
rundownArrayOfIds,
rundownImportValidator,
rundownPatchValidator,
rundownPostValidator,
} from './rundown.validation.js';
@@ -147,6 +158,39 @@ 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 ======================
// #region operations on rundown entries ==========================
@@ -4,6 +4,7 @@ import {
CustomFields,
EntryId,
EventPostPayload,
ImportedFields,
InsertOptions,
LogOrigin,
OntimeEntry,
@@ -12,6 +13,7 @@ import {
ProjectRundowns,
RefetchKey,
Rundown,
RundownImportMergeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
@@ -25,6 +27,7 @@ import { makeNewRundown } from '../../models/dataModel.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import {
createTransaction,
customFieldMutation,
@@ -33,8 +36,19 @@ import {
rundownMutation,
updateBackgroundRundown,
} from './rundown.dao.js';
import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
import {
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
@@ -42,6 +56,8 @@ import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.util
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertSingleInsertAnchor(eventData);
// we allow the user to provide an ID, but make sure it is unique
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
throw new Error(`Event with ID ${eventData.id} already exists`);
@@ -58,14 +74,29 @@ export async function addEntry(rundownId: string, eventData: EventPostPayload):
parent = maybeParent;
}
assertInsertAnchorInOrder(rundown, parent, eventData);
// normalise the position of the event in the rundown order
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
const insertPosition = (() => {
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
const newEntry = generateEvent(rundown, eventData, afterId, parent?.id);
const newEntry = generateEvent(rundown, eventData, insertPosition.cueAfterId, parent?.id);
// make mutations to rundown
rundownMutation.add(rundown, newEntry, afterId, parent);
rundownMutation.add(rundown, newEntry, parent, insertPosition.afterId, insertPosition.beforeId);
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
@@ -385,7 +416,10 @@ export async function swapEvents(rundownId: string, fromId: EntryId, toId: Entry
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
assertSingleInsertAnchor(options);
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertInsertAnchorExists(rundown, options);
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
@@ -656,8 +690,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 and marks it as the currently loaded one.
* Switching to a rundown always stops playback.
*/
export async function initRundown(
rundown: Readonly<Rundown>,
@@ -679,6 +713,25 @@ 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) {
const emptyRundown = makeNewRundown();
emptyRundown.title = title;
@@ -743,6 +796,86 @@ export async function duplicateExistingRundown(id: string) {
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
* @throws if attempting to delete the loaded rundown or the last rundown in the project
@@ -2,6 +2,7 @@ import {
CustomFields,
EntryCustomFields,
EntryId,
ImportedFields,
OntimeBaseEvent,
OntimeDelay,
OntimeEntry,
@@ -11,12 +12,14 @@ import {
ProjectRundown,
ProjectRundowns,
Rundown,
RundownEntries,
SupportedEntry,
TimeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
isPlayableEvent,
} from 'ontime-types';
import {
createDelay,
@@ -67,6 +70,50 @@ export function generateEvent(
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 {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
@@ -225,6 +272,83 @@ export function getUniqueId(rundown: Rundown): EntryId {
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 */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
@@ -1,4 +1,5 @@
import { body, param } from 'express-validator';
import type { EntryId, InsertOptions, OntimeGroup, Rundown } from 'ontime-types';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -11,20 +12,53 @@ export const rundownPatchValidator = [
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 ======================
// #region operations on rundown entries ==========================
export const entryPostValidator = [
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
body('after').optional().isString(),
body('before').optional().isString(),
body('after')
.optional()
.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,
];
export const clonePostValidator = [
body('after').optional().isString(),
body('before').optional().isString(),
body('after')
.optional()
.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,
];
@@ -72,4 +106,44 @@ export const entryRenumberValidator = [
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 =======================
@@ -153,6 +153,41 @@ describe('mcp.service', () => {
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 () => {
await expect(
batchCreateEntriesForMcp({
+2 -2
View File
@@ -71,10 +71,10 @@ Read the ontime://schema resource if you need a data model reference.
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.
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and avoid duplicating existing entries.
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.
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
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.
Entry type guidance:
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
+29 -13
View File
@@ -63,6 +63,11 @@ export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEnt
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 {
return args.rundownId ?? getCurrentRundownId();
@@ -133,20 +138,24 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
const { type = SupportedEntry.Event, after, before } = args;
const insertOptions = {
...(after !== undefined ? { after } : {}),
...(before !== undefined ? { before } : {}),
};
switch (type) {
case SupportedEntry.Delay:
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
return { type: SupportedEntry.Delay, duration: args.duration, ...insertOptions };
case SupportedEntry.Milestone: {
const { cue, title, note, colour, custom } = args;
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, ...insertOptions };
}
case SupportedEntry.Group:
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
return { type: SupportedEntry.Group, title: args.title, after, before };
return { type: SupportedEntry.Group, title: args.title, ...insertOptions };
case SupportedEntry.Event: {
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
return { type: SupportedEntry.Event, ...eventFields };
const { type: _type, rundownId: _rundownId, after: _after, before: _before, ...eventFields } = args;
return { type: SupportedEntry.Event, ...eventFields, ...insertOptions };
}
default:
throw new Error(`Invalid entry type: ${String(type)}`);
@@ -259,20 +268,25 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
}
export async function batchCreateEntriesForMcp(
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
) {
const { entries = [], after } = args;
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
const { entries = [], after, before } = args;
validateBatchCreateEntries(entries);
const allEntries = flattenBatchCreateEntries(entries);
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
const rundownId = resolveTargetRundownId(args);
let previousId = after;
let previousId: EntryId | undefined;
const created: OntimeEntry[] = [];
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
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
const entry = await createBatchEntry(rundownId, entryArgs, previousId, undefined, firstInsertOptions);
created.push(...entry.created);
previousId = entry.entry.id;
}
@@ -303,6 +317,7 @@ async function createBatchEntry(
entryArgs: BatchCreateEntryArgs,
previousId?: EntryId,
parentId?: EntryId,
firstInsertOptions?: InsertOptions,
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
if (parentId && entryArgs.type === SupportedEntry.Group) {
throw new Error('Cannot create a group inside another group.');
@@ -311,8 +326,9 @@ async function createBatchEntry(
const { children: _children, ...createArgs } = entryArgs;
const payload = toEntryPayload(createArgs);
const insertOptions = {
...(previousId ? { after: previousId } : {}),
...(parentId ? { parent: parentId } : {}),
...(previousId !== undefined ? { after: previousId } : {}),
...(parentId !== undefined ? { parent: parentId } : {}),
...(previousId === undefined ? firstInsertOptions : {}),
};
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
+25 -6
View File
@@ -96,7 +96,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_create_entry',
description:
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
'Create a new entry. Omit after/before to append at the end, use after: true to explicitly append, use before: true to prepend, or use before/after with an entry ID to position the entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
inputSchema: {
type: 'object',
properties: {
@@ -114,8 +114,14 @@ export const TOOL_DEFINITIONS = [
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
},
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
after: { type: 'string', description: 'Insert after this entry ID' },
before: { type: 'string', description: 'Insert before this entry ID' },
after: {
type: ['string', 'boolean'],
description: 'Insert after this entry ID, or true to append',
},
before: {
type: ['string', 'boolean'],
description: 'Insert before this entry ID, or true to prepend',
},
...EVENT_WRITABLE_FIELDS,
},
},
@@ -218,13 +224,20 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_batch_create_entries',
description:
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; omit after/before to append the first entry at the end, use after: true to explicitly append, use before: true to prepend, or use after/before with an entry ID to position the first top-level entry. Subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
inputSchema: {
type: 'object',
required: ['entries'],
properties: {
...RUNDOWN_TARGET_FIELD,
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
after: {
type: ['string', 'boolean'],
description: 'Insert the first entry after this entry ID, or true to append',
},
before: {
type: ['string', 'boolean'],
description: 'Insert the first entry before this entry ID, or true to prepend',
},
entries: {
type: 'array',
description: 'Array of entries to create, in desired order',
@@ -588,7 +601,13 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_batch_create_entries: async (args) => {
return ok(
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
await batchCreateEntriesForMcp(
args as TargetRundownArgs & {
entries: BatchCreateEntryArgs[];
after?: EntryId | true;
before?: EntryId | true;
},
),
);
},
+2 -1
View File
@@ -117,8 +117,9 @@ app.use(`${prefix}/external`, (req, res) => {
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
// Serve legacy timer for old browsers that don't support modern JS
// dotfiles must be allowed since the install path can contain dot directories (eg AppImage mounts in /tmp/.mount_*)
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
res.sendFile(srcFiles.timerLegacy);
res.sendFile(srcFiles.timerLegacy, { dotfiles: 'allow' });
});
// Base route for static files
@@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000); // <--- offset is always the overtime
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
});
it('handles time-to-end started after the end time', () => {
@@ -165,6 +165,13 @@ class RuntimeService {
}
}
/**
* Returns the id of the currently loaded event, or null if none is loaded
*/
public getLoadedEventId(): EntryId | null {
return runtimeState.getState().eventNow?.id ?? null;
}
/**
* Called when the underlying data has changed,
* we check if the change affects the runtime
+3 -3
View File
@@ -180,13 +180,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
// in case of count to end, the absolute offset is overtime and added time
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
// in case of count to end, the absolute offset is just the overtime
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
return { absolute, relative };
}
/**
@@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => {
eventNow: rundown.entries[0],
});
});
test('countToEnd entries decouple the link chain for subsequent events', () => {
// Event 0 (loaded): no countToEnd, linkStart=true
// Event 1: countToEnd=true → breaks the link chain
// Event 2: linkStart=true → unlinked (chain broken by event 1)
// Event 3: flag event → also unlinked
const rundown = makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
0: makeOntimeEvent({
id: '0',
parent: 'group',
timeStart: 0,
duration: 3600000,
countToEnd: false,
linkStart: true,
gap: 0,
} as any),
1: makeOntimeEvent({
id: '1',
parent: 'group',
timeStart: 3600000,
duration: 3600000,
countToEnd: true,
linkStart: true,
gap: 0,
} as any),
2: makeOntimeEvent({
id: '2',
parent: 'group',
timeStart: 7200000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
3: makeOntimeEvent({
id: '3',
parent: null,
timeStart: 10800000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
},
order: ['group', '0', '1', '2', '3'],
});
const state = {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// _group is the last event in the group (event 2)
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
// accumulatedGap includes event 1's duration carried forward to event 2
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
expect(state._group).toMatchObject({
event: rundown.entries[2],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
// so previousWasCountToEnd is null and no further duration is carried forward
expect(state._flag).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _end (event 3): also unlinked because the chain was broken at event 1
expect(state._end).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
});
});
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
// Event 0 (loaded): no countToEnd, linkStart=true
// Event 1: linkStart=true → linked to loaded
// Event 2: countToEnd=true → breaks the chain (also last in group)
// Event 3: linkStart=true → unlinked (chain broken by event 2)
const rundown = makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
0: makeOntimeEvent({
id: '0',
parent: 'group',
timeStart: 0,
duration: 3600000,
countToEnd: false,
linkStart: true,
gap: 0,
} as any),
1: makeOntimeEvent({
id: '1',
parent: 'group',
timeStart: 3600000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
2: makeOntimeEvent({
id: '2',
parent: 'group',
timeStart: 7200000,
duration: 3600000,
countToEnd: true,
linkStart: true,
gap: 0,
} as any),
3: makeOntimeEvent({
id: '3',
parent: null,
timeStart: 10800000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
},
order: ['group', '0', '1', '2', '3'],
});
const state = {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// _group is the last event in the group (event 2)
// isLinkedToLoaded is true because no preceding event had countToEnd
// accumulatedGap is 0 because no preceding event was countToEnd
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
expect(state._group).toMatchObject({
event: rundown.entries[2],
isLinkedToLoaded: true,
accumulatedGap: 0,
});
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
// accumulatedGap includes event 2's duration carried forward
expect(state._flag).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _end (event 3): unlinked because event 2 has countToEnd=true
expect(state._end).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
});
});
});
+21 -5
View File
@@ -23,6 +23,7 @@ import {
calculateDuration,
checkIsNow,
dayInMs,
getExpectedEnd,
getExpectedStart,
getLastEventNormal,
isPlaybackActive,
@@ -831,7 +832,6 @@ function getExpectedTimes(state = runtimeState) {
state.offset.expectedRundownEnd = null;
state.offset.expectedGroupEnd = null;
state.offset.expectedFlagStart = null;
state.offset.expectedRundownEnd = null;
const { offset } = state;
const { plannedStart, actualStart } = state.rundown;
@@ -852,7 +852,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
}
}
@@ -884,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = expectedStart + event.duration;
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
}
}
@@ -927,6 +927,7 @@ export function loadGroupFlagAndEnd(
let accumulatedGap = 0;
let isLinkedToLoaded = true;
let previousWasCountToEnd: Maybe<number> = null;
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
const entry = entries[playableEventOrder[idx]];
@@ -934,8 +935,23 @@ export function loadGroupFlagAndEnd(
if (isOntimeEvent(entry)) {
if (idx !== currentIndex) {
// we only accumulate data after the loaded event
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
if (previousWasCountToEnd !== null) {
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
* and break the link to the loaded event since countToEnd events reset the schedule
*/
accumulatedGap += entry.gap + previousWasCountToEnd;
isLinkedToLoaded = false;
} else {
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
}
if (entry.countToEnd) {
previousWasCountToEnd = entry.duration;
} else {
previousWasCountToEnd = null;
}
// and the loaded event is not allowed to be the next flag
if (!foundFlag && metadata.flags.includes(entry.id)) {
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
await page.getByRole('button', { name: 'Preview import' }).click();
await page.getByRole('button', { name: 'Apply import' }).click();
await expect(page.getByText('Import complete')).toBeVisible();
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
await page.getByRole('button', { name: 'Reset flow' }).click();
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
await page.getByRole('button', { name: 'Import another' }).click();
// verify the data in the rundown
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.10.0",
"version": "4.11.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.10.0",
"version": "4.11.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -4,8 +4,8 @@ import type { MaybeNumber } from '../../utils/utils.type.js';
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
export type InsertOptions = {
after?: EntryId;
before?: EntryId;
after?: EntryId | true;
before?: EntryId | true;
};
export type EventPostPayload = Partial<OntimeEntry> & InsertOptions;
@@ -18,3 +18,44 @@ export type SpreadsheetPreviewResponse = {
customFields: CustomFields;
summary: RundownSummary;
};
/**
* How elements matched by id should be reconciled when importing into an existing rundown
* - override: imported data replaces the whole matched element
* - merge: imported data updates only the fields the spreadsheet supplied, keeping the rest
*/
export type RundownImportMergeStrategy = 'override' | 'merge';
/**
* What an import does with the data
* - override / merge apply the import onto an existing rundown (reconciling matched elements)
* - new creates a fresh rundown from the import
*/
export type RundownImportMode = RundownImportMergeStrategy | 'new';
/**
* The fields an import supplies, i.e. the columns the spreadsheet maps.
* This is the complete description of what the incoming data provides, so a merge can apply exactly
* these fields onto a matched event and keep everything else (e.g. automations) untouched.
*/
export type ImportedFields = {
/** mapped OntimeEvent fields (the import-map keys are OntimeEvent field names) */
event: string[];
/** mapped custom field keys */
custom: string[];
};
/**
* Payload for the rundown import endpoint
* - override / merge apply the import onto the target rundown (targetRundownId required)
* - new creates a fresh rundown from the import
*/
export type RundownImportPayload = {
mode: RundownImportMode;
/** required when mode is 'override' or 'merge' */
targetRundownId?: string;
rundown: Rundown;
customFields: CustomFields;
/** the fields the spreadsheet supplies; used by 'merge' to patch only those on a matched event */
providedFields?: ImportedFields;
};

Some files were not shown because too many files have changed in this diff Show More