Compare commits

...

2 Commits

Author SHA1 Message Date
Carlos Valente 8d2d8ef979 feat: add user defined translations (#1756)
* feat: add user defined translations

* add refetch key for translation (#1757)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 16:06:03 +02:00
Carlos Valente ae15f3cdc5 Alpha 5 (#1741)
* refactor: align header columns

* refactor: improve scrollbar visibility

* refactor: center align table elements

* refactor: make param elements stateful

* fix: issue with collapsed elements not loosing value

* fix: prevent search params containing multiple alias references

* fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744)

* refactor: disable group action for elements in groups

* refactor: move context menu items into the event element (#1747)

* feat: sheet import new features for v4 (#1730)

* import milestone

* fixup! import milestone

* test: milestone import

* add entries to group

stop on new group or on group-end type

* fixup! add entries to group

* cleanup

* add event target duration

* link start if undefined

* add skip import type

* extract some to the excel paresing functions

* tweaks to presentation

* move file

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* fix: notify runtimeStore of events bieng groupd

* fix: improve authentication and stage detection in demo

* chore: ship logo with project

* fix: client is referenced by name

* fix: prevent reflow in event editor

* fix: stale render on selected event due to ref mismatch

* Create/Load/Delete multiple rundowns (#1696)

* refactor: restore last loaded rundown

* refactor: initialise rundown in ProjectService

* feat: allow switching rundowns

ensure on coordination between the db object and the working object

server provide list of rundowns

implement switch in the UI

implement delete

implement new rundown button

* fix: render order for floating button

* refactor: appropriate names to service

* refactor: rundown endpoints

* refactor: save last loaded rundown ID

* refactor: rundown management UI

* refactor: emit refetch all on project load

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: recover single event subscription

* fixup! feat: sheet import new features for v4 (#1730)

* fix: prevent dropping a group inside another

* fixup! refactor: move context menu items into the event element (#1747)

* fix: prevent stale references to custom fields

* fix: propagate updates to all rundowns

* refactor: client rundown metadata (#1728)

* generate metadata in the hook

* move test

* ensure there is always a last element

* use for-loop

* update metadata in useEfect

* fully extract metadata generation

* use direct assignment

* cleanup

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: small imporvement and tests for coerce functions (#1752)

* refactor: small imporvement and tests for coerce functions

add test `coerceString`

add test `coerceBoolean`

add test `coerceColour`

* remove old todo

* fix: consistent quick add behaviour

* refactor: create flat rundown with metadata

* fix: show add buttons on top

* feat: allow editing milestones

* refactor: style tweaks to rundown elements

refactor: milestones are full width

refactor: cuesheet header alignment

fix: editor styling in cuesheet

* refactor: virtualise table

* refactor: improve overscan (#1758)

* bump version to 4.0.0-alpha.5

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 15:50:20 +02:00
125 changed files with 3375 additions and 2227 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.0-alpha.4", "version": "4.0.0-alpha.5",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-alpha.4", "version": "4.0.0-alpha.5",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -29,6 +29,7 @@
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-router": "^7.8.0", "react-router": "^7.8.0",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0", "web-vitals": "^5.1.0",
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
+22 -1
View File
@@ -1,6 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import { TranslationObject } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
const assetsPath = `${apiEntryUrl}/assets`; const assetsPath = `${apiEntryUrl}/assets`;
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`); const res = await axios.post(`${assetsPath}/css/restore`);
return res.data; return res.data;
} }
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
return res.data;
}
/**
* HTTP request to post user translation
*/
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
await axios.post(`${assetsPath}/translations`, {
translation,
});
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
}
+3
View File
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user'; const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css'; const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+65 -47
View File
@@ -1,17 +1,11 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
/** /**
* HTTP request to fetch a list of existing rundowns * HTTP request to fetch a list of existing rundowns
@@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
} }
/** /**
* HTTP request to fetch all events * HTTP request to fetch all entries in the currently loaded rundown
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/current`);
return res.data; return res.data;
} }
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
}
/**
* HTTP request to create a new rundown
*/
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(rundownPath, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
/** /**
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> { export async function postAddEntry(
return axios.post(rundownPath, data); rundownId: string,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
} }
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
type BatchEditEntry = { export type BatchEditEntry = {
data: Partial<OntimeEvent>; data: Partial<OntimeEvent>;
ids: string[]; ids: EntryId[];
}; };
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
export type ReorderEntry = { export type ReorderEntry = {
@@ -64,60 +85,57 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
export type SwapEntry = {
from: string;
to: string;
};
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> { export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/swap`, data); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a group
*/
export async function requestUngroup(groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${groupId}`);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request to delete entries * HTTP request for dissolving of a group
*/ */
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> { export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.delete(rundownPath, { data: { ids: entryIds } }); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete all events * HTTP request to delete entries of a given rundown
*/ */
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> { export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/all`); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/**
* HTTP request to delete all entries of a given rundown
*/
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`);
}
// #endregion operations on rundown entries =======================
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker'; import SwatchPicker from '../input/colour-input/SwatchPicker';
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
return value; return value;
}; };
export default function InlineColourPicker(props: InlineColourPickerProps) { export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value)); const [colour, setColour] = useState(() => ensureHex(value));
useEffect(() => {
setColour(ensureHex(value));
}, [value]);
return ( return (
<div className={style.inline}> <div className={style.inline}>
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor /> <SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
@@ -1,10 +1,10 @@
import { useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select from '../select/Select'; import Select, { SelectOption } from '../select/Select';
import Switch from '../switch/Switch'; import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker'; import InlineColourPicker from './InlineColourPicker';
@@ -35,7 +35,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
return <span className={style.empty}>No options available</span>; return <span className={style.empty}>No options available</span>;
} }
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />; return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
} }
if (type === 'multi-option') { if (type === 'multi-option') {
@@ -43,7 +43,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
} }
if (type === 'boolean') { if (type === 'boolean') {
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />; return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
} }
if (type === 'number') { if (type === 'number') {
@@ -63,15 +63,13 @@ export default function ParamInput({ paramField }: ParamInputProps) {
} }
if (type === 'colour') { if (type === 'colour') {
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`; return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
return <InlineColourPicker name={id} value={currentvalue} />;
} }
const defaultStringValue = searchParams.get(id) ?? defaultValue; const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
const { placeholder } = paramField; const { placeholder } = paramField;
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />; return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
} }
interface EditFormMultiOptionProps { interface EditFormMultiOptionProps {
@@ -83,7 +81,12 @@ function MultiOption({ paramField }: EditFormMultiOptionProps) {
const { id, values, defaultValue = [''] } = paramField; const { id, values, defaultValue = [''] } = paramField;
const optionFromParams = searchParams.getAll(id); const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue); const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
useEffect(() => {
const params = searchParams.getAll(id);
setParamState(params.length ? params : defaultValue);
}, [searchParams, id, defaultValue]);
const toggleValue = (value: string, checked: boolean) => { const toggleValue = (value: string, checked: boolean) => {
if (checked) { if (checked) {
@@ -129,5 +132,52 @@ interface ControlledSwitchProps {
} }
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) { function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
const [checked, setChecked] = useState(initialValue); const [checked, setChecked] = useState(initialValue);
// synchronise checked state
useEffect(() => {
setChecked(initialValue);
}, [initialValue]);
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />; return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
} }
interface ControlledSelectProps {
id: string;
initialValue?: string;
options: SelectOption[];
}
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
const [selected, setSelected] = useState(initialValue);
// synchronise selected state
useEffect(() => {
setSelected(initialValue);
}, [initialValue]);
return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
);
}
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
id: string;
initialValue: T;
}
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
const [value, setValue] = useState(initialValue);
// synchronise selected state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<Input
height='large'
name={id}
value={value}
onChange={(event) => setValue(event.target.value as T)}
{...inputProps}
/>
);
}
@@ -1,4 +1,4 @@
import { FormEvent, memo, useReducer } from 'react'; import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
@@ -12,7 +12,7 @@ import Info from '../info/Info';
import { ViewOption } from './viewParams.types'; import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils'; import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store'; import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsShare } from './ViewParamShare'; import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection'; import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
@@ -24,12 +24,9 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor); export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
// TODO: can we ensure that the options update when the user loads an alias?
const [_, setSearchParams] = useSearchParams(); const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
// TODO: we dont want this as a permanent option
const forceRender = useReducer((x) => x + 1, 0)[1];
const handleClose = () => { const handleClose = () => {
close(); close();
@@ -37,7 +34,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const resetParams = () => { const resetParams = () => {
setSearchParams(); setSearchParams();
forceRender();
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -46,7 +42,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
forceRender();
}; };
return ( return (
@@ -71,7 +66,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
{viewSettings.overrideStyles && ( {viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info> <Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)} )}
<ViewParamsShare target={target} /> <ViewParamsPresets target={target} />
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}> <form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => ( {viewOptions.map((section) => (
<ViewParamsSection <ViewParamsSection
@@ -5,6 +5,9 @@
gap: 0.25rem; gap: 0.25rem;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
} }
.preset { .preset {
@@ -5,17 +5,19 @@ import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import style from './ViewParamsShare.module.scss'; import style from './ViewParamsPresets.module.scss';
/** /**
* Shows a list of presets for the current view * Shows a list of presets for the current view
*/ */
export function ViewParamsShare({ target }: { target: OntimeView }) { export function ViewParamsPresets({ target }: { target: OntimeView }) {
const { viewPresets } = useViewUrlPresets(target); const { viewPresets } = useViewUrlPresets(target);
const [_, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const handleRecall = (preset: URLPreset) => { const handleRecall = (preset: URLPreset) => {
setSearchParams(`${preset.search}&alias=${preset.alias}`); const newSearch = new URLSearchParams(preset.search);
newSearch.set('alias', preset.alias);
setSearchParams(newSearch);
}; };
if (viewPresets.length === 0) { if (viewPresets.length === 0) {
@@ -25,12 +27,12 @@ export function ViewParamsShare({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
{viewPresets.map((preset) => { {viewPresets.map((preset) => {
const active = window.location.search.includes(`alias=${preset.alias}`); const active = searchParams.get('alias') === preset.alias;
return ( return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}> <div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div> <div>{preset.alias}</div>
<Button <Button
variant='subtle-white' variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)} onClick={() => handleRecall(preset)}
disabled={active} disabled={active}
className={style.presetActions} className={style.presetActions}
@@ -44,3 +44,7 @@
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005); transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg; rotate: 180deg;
} }
.hidden {
display: none;
}
@@ -47,15 +47,11 @@ interface SectionContentsProps {
} }
function SectionContents({ options, collapsed }: SectionContentsProps) { function SectionContents({ options, collapsed }: SectionContentsProps) {
if (collapsed) {
return null;
}
return ( return (
<> <>
{options.map((option) => { {options.map((option) => {
return ( return (
<label key={option.title} className={style.label}> <label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span> <span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span> <span className={style.description}>{option.description}</span>
<ParamInput paramField={option} /> <ParamInput paramField={option} />
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
values: MultiselectOption[]; values: MultiselectOption[];
defaultValue?: string; defaultValue?: string[];
}; };
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { langEn } from 'ontime-types';
import { getUserTranslation } from '../../common/api/assets';
import { TRANSLATION } from '../../common/api/constants';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: getUserTranslation,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? langEn, status, refetch };
}
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types'; import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { fetchProjectRundownList } from '../api/rundown'; import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -13,10 +13,43 @@ export function useProjectRundowns() {
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList, queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching }; return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
} }
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({
mutationFn: createRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: load } = useMutation({
mutationFn: loadRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
return { create, remove, load };
}
@@ -5,6 +5,8 @@ import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
@@ -26,14 +28,18 @@ export default function useRundown() {
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
/** /**
* Provides access to a flat rundown * Provides access to a flat rundown
* built from the order and rundown fields * built from the order and rundown fields
@@ -68,6 +74,14 @@ export function useFlatRundown() {
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
}
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*/ */
+166 -94
View File
@@ -30,7 +30,6 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
@@ -59,15 +58,25 @@ export const useEntryActions = () => {
defaultEndAction, defaultEndAction,
} = useEditorSettings(); } = useEditorSettings();
/**
* Returns the currently loaded rundown
*/
const getCurrentRundownData = useCallback(() => {
return queryClient.getQueryData<Rundown>(RUNDOWN);
}, [queryClient]);
/**
* Looks for an entry with a given ID in the currently loaded rundown
*/
const getEntryById = useCallback( const getEntryById = useCallback(
(eventId: string): OntimeEntry | undefined => { (eventId: EntryId): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const cachedRundown = getCurrentRundownData();
if (!cachedRundown?.entries) { if (!cachedRundown?.entries) {
return; return;
} }
return cachedRundown.entries[eventId]; return cachedRundown.entries[eventId];
}, },
[queryClient], [getCurrentRundownData],
); );
/** /**
@@ -75,8 +84,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
// TODO(v4): optimistic create entry mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
mutationFn: postAddEntry, onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -85,13 +94,19 @@ export const useEntryActions = () => {
*/ */
const addEntry = useCallback( const addEntry = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => { async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
const newEntry: TransientEventPayload = { ...entry, id: generateId() }; const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -135,21 +150,21 @@ export const useEntryActions = () => {
} }
try { try {
await addEntryMutation(newEntry); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
addEntryMutation, getCurrentRundownData,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious, linkPrevious,
queryClient, defaultDuration,
defaultDangerTime,
defaultWarnTime,
defaultTimerType,
defaultEndAction,
defaultTimeStrategy,
addEntryMutation,
], ],
); );
@@ -158,22 +173,28 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: postCloneEntry, mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
/** /**
* Clone a selection * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId) => {
try { try {
await cloneEntryMutation(entryId); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
}, },
[cloneEntryMutation], [cloneEntryMutation, getCurrentRundownData],
); );
/** /**
@@ -181,9 +202,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: updateEntryMutation } = useMutation({ const { mutateAsync: updateEntryMutation } = useMutation({
mutationFn: putEditEntry, mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async ([_rundownId, newEvent]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -211,7 +232,9 @@ export const useEntryActions = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData); if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -226,19 +249,17 @@ export const useEntryActions = () => {
const updateEntry = useCallback( const updateEntry = useCallback(
async (entry: Partial<OntimeEntry>) => { async (entry: Partial<OntimeEntry>) => {
try { try {
await updateEntryMutation(entry); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await updateEntryMutation([rundownId, entry]);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[updateEntryMutation], [getCurrentRundownData, updateEntryMutation],
);
const updateCustomField = useCallback(
async (entryId: EntryId, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } });
},
[updateEntry],
); );
/** /**
@@ -250,6 +271,11 @@ export const useEntryActions = () => {
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
// an empty value with no lock has no domain validity // an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') { if (!lockOnUpdate && value === '') {
return; return;
@@ -279,7 +305,7 @@ export const useEntryActions = () => {
} }
try { try {
await updateEntryMutation(newEvent); await updateEntryMutation([rundownId, newEvent]);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -331,7 +357,7 @@ export const useEntryActions = () => {
return previousEnd; return previousEnd;
} }
}, },
[updateEntryMutation, queryClient], [getCurrentRundownData, updateEntryMutation, queryClient],
); );
/** /**
@@ -339,8 +365,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: batchUpdateEventsMutation } = useMutation({ const { mutateAsync: batchUpdateEventsMutation } = useMutation({
mutationFn: putBatchEditEvents, mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
onMutate: async ({ ids, data }) => { onMutate: async ([_rundownId, data]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -348,7 +374,7 @@ export const useEntryActions = () => {
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousRundown) { if (previousRundown) {
const eventIds = new Set(ids); const eventIds = new Set(data.ids);
const newRundown = { ...previousRundown.entries }; const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
@@ -395,14 +421,19 @@ export const useEntryActions = () => {
}); });
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => { async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
try { try {
await batchUpdateEventsMutation({ ids: eventIds, data }); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await batchUpdateEventsMutation([rundownId, { data, ids: eventIds }]);
} catch (error) { } catch (error) {
logAxiosError('Error updating events', error); logAxiosError('Error updating events', error);
} }
}, },
[batchUpdateEventsMutation], [batchUpdateEventsMutation, getCurrentRundownData],
); );
/** /**
@@ -410,9 +441,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: deleteEntryMutation } = useMutation({ const { mutateAsync: deleteEntryMutation } = useMutation({
mutationFn: deleteEntries, mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
// we optimistically update here // we optimistically update here
onMutate: async (entryIds: EntryId[]) => { onMutate: async ([_rundownId, entryIds]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -454,12 +485,17 @@ export const useEntryActions = () => {
const deleteEntry = useCallback( const deleteEntry = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
try { try {
await deleteEntryMutation(entryIds); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteEntryMutation([rundownId, entryIds]);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[deleteEntryMutation], [deleteEntryMutation, getCurrentRundownData],
); );
/** /**
@@ -467,7 +503,7 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: deleteAllEntriesMutation } = useMutation({ const { mutateAsync: deleteAllEntriesMutation } = useMutation({
mutationFn: requestDeleteAll, mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
// cancel ongoing queries // cancel ongoing queries
@@ -506,18 +542,24 @@ export const useEntryActions = () => {
*/ */
const deleteAllEntries = useCallback(async () => { const deleteAllEntries = useCallback(async () => {
try { try {
await deleteAllEntriesMutation(); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteAllEntriesMutation([rundownId]);
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [deleteAllEntriesMutation]); }, [deleteAllEntriesMutation, getCurrentRundownData]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
* @private * @private
*/ */
const { mutateAsync: applyDelayMutation } = useMutation({ const { mutateAsync: applyDelayMutation } = useMutation({
mutationFn: requestApplyDelay, mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -543,12 +585,17 @@ export const useEntryActions = () => {
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: EntryId) => { async (delayEventId: EntryId) => {
try { try {
await applyDelayMutation(delayEventId); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await applyDelayMutation([rundownId, delayEventId]);
} catch (error) { } catch (error) {
logAxiosError('Error applying delay', error); logAxiosError('Error applying delay', error);
} }
}, },
[applyDelayMutation], [applyDelayMutation, getCurrentRundownData],
); );
/** /**
@@ -556,7 +603,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: ungroupMutation } = useMutation({ const { mutateAsync: ungroupMutation } = useMutation({
mutationFn: requestUngroup, mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -579,12 +627,17 @@ export const useEntryActions = () => {
const ungroup = useCallback( const ungroup = useCallback(
async (groupId: EntryId) => { async (groupId: EntryId) => {
try { try {
await ungroupMutation(groupId); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await ungroupMutation([rundownId, groupId]);
} catch (error) { } catch (error) {
logAxiosError('Error dissolving group', error); logAxiosError('Error dissolving group', error);
} }
}, },
[ungroupMutation], [getCurrentRundownData, ungroupMutation],
); );
/** /**
@@ -592,7 +645,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: groupEntriesMutation } = useMutation({ const { mutateAsync: groupEntriesMutation } = useMutation({
mutationFn: requestGroupEntries, mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
requestGroupEntries(rundownId, entryIds),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -617,19 +672,24 @@ export const useEntryActions = () => {
if (entryIds.length === 0) return; if (entryIds.length === 0) return;
try { try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
if (entryIds.length === 1) { if (entryIds.length === 1) {
await groupEntriesMutation(entryIds); await groupEntriesMutation([rundownId, entryIds]);
} else { } else {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN); // the user selection may be out of order
if (!rundown) return; const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
const orderedIds = orderEntries(entryIds, rundown.flatOrder); await groupEntriesMutation([rundownId, orderedIds]);
await groupEntriesMutation(orderedIds);
} }
} catch (error) { } catch (error) {
logAxiosError('Error grouping entries', error); logAxiosError('Error grouping entries', error);
} }
}, },
[groupEntriesMutation, queryClient], [getCurrentRundownData, groupEntriesMutation],
); );
/** /**
@@ -637,9 +697,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: reorderEntryMutation } = useMutation({ const { mutateAsync: reorderEntryMutation } = useMutation({
mutationFn: patchReorderEntry, mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
// Mutation finished, failed or successful onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
// Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
@@ -650,34 +709,36 @@ export const useEntryActions = () => {
*/ */
const move = useCallback( const move = useCallback(
async (entryId: EntryId, direction: 'up' | 'down') => { async (entryId: EntryId, direction: 'up' | 'down') => {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundown.flatOrder, rundown.entries)
: moveDown(entryId, rundown.flatOrder, rundown.entries);
if (!destinationId) {
return; // noop
}
try { try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundownData.flatOrder, rundownData.entries)
: moveDown(entryId, rundownData.flatOrder, rundownData.entries);
if (!destinationId) {
return; // noop
}
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
entryId, entryId,
destinationId, destinationId,
order, order,
}; };
await reorderEntryMutation(reorderObject); await reorderEntryMutation([rundownId, reorderObject]);
// the rundown needs to know whether we moved into a group
return rundownData.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
// the rundown needs to know whether we moved into a group return undefined;
return rundown.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
}, },
[queryClient, reorderEntryMutation], [getCurrentRundownData, reorderEntryMutation],
); );
/** /**
* Reorders a given entry * Reorders a given entry
@@ -685,18 +746,25 @@ export const useEntryActions = () => {
const reorderEntry = useCallback( const reorderEntry = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try { try {
const reorderObject: ReorderEntry = { const rundownId = getCurrentRundownData()?.id;
entryId, if (!rundownId) {
destinationId, throw new Error('Rundown not initialised');
order, }
};
await reorderEntryMutation(reorderObject); await reorderEntryMutation([
rundownId,
{
entryId,
destinationId,
order,
},
]);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component throw error; // rethrow to handle in the component
} }
}, },
[reorderEntryMutation], [getCurrentRundownData, reorderEntryMutation],
); );
/** /**
@@ -704,9 +772,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: swapEventsMutation } = useMutation({ const { mutateAsync: swapEventsMutation } = useMutation({
mutationFn: requestEventSwap, mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
// we optimistically update here // we optimistically update here
onMutate: async ({ from, to }) => { onMutate: async ([_rundownId, from, to]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -755,14 +823,19 @@ export const useEntryActions = () => {
* Swaps the schedule of two events * Swaps the schedule of two events
*/ */
const swapEvents = useCallback( const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => { async (from: EntryId, to: EntryId) => {
try { try {
await swapEventsMutation({ from, to }); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await swapEventsMutation([rundownId, from, to]);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[swapEventsMutation], [getCurrentRundownData, swapEventsMutation],
); );
return { return {
@@ -780,7 +853,6 @@ export const useEntryActions = () => {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
updateCustomField,
}; };
}; };
@@ -0,0 +1,302 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeGroup,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'group',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = initRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
});
expect(process(demoEvents['group'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['group'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['group'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: true,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
isFirstAfterGroup: true,
});
});
it('populates previousEntries in groups', () => {
const rundownStartsWithGroup = {
group: {
id: 'group',
type: SupportedEntry.Group,
colour: 'red',
entries: ['1', '2'],
} as OntimeGroup,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = initRundownMetadata(null);
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithGroup.group.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithGroup['1'],
previousEntryId: rundownStartsWithGroup.group.id,
thisId: rundownStartsWithGroup['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
previousEvent: rundownStartsWithGroup['1'],
latestEvent: rundownStartsWithGroup['2'],
previousEntryId: rundownStartsWithGroup['1'].id,
thisId: rundownStartsWithGroup['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
});
});
@@ -0,0 +1,173 @@
import {
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeMilestone,
PlayableEvent,
Rundown,
} from 'ontime-types';
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
export type RundownMetadata = {
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
thisId: MaybeString;
eventIndex: number;
isPast: boolean;
isNextDay: boolean;
totalGap: number;
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
isLoaded: boolean;
groupId: MaybeString;
groupColour: string | undefined;
groupEntries: number | undefined;
isFirstAfterGroup: boolean;
};
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
export const lastMetadataKey = 'LAST';
export type RundownMetadataObject = Record<string, Readonly<RundownMetadata>>;
/**
* Generates a Rundown Metadata object from a rundown
*/
export function getRundownMetadata(
data: Pick<Rundown, 'entries' | 'flatOrder'>,
selectedEventId: MaybeString,
): RundownMetadataObject {
const { metadata, process } = initRundownMetadata(selectedEventId);
// keep a single reference to the metadata which we override for every entry
let lastSnapshot = metadata;
const rundownMetadata: RundownMetadataObject = {};
for (const id of data.flatOrder) {
const entry = data.entries[id];
lastSnapshot = process(entry);
rundownMetadata[id] = lastSnapshot;
}
// ensure some blank data even for empty rundowns
rundownMetadata[lastMetadataKey] = lastSnapshot;
return rundownMetadata;
}
export function getFlatRundownMetadata(
data: Pick<Rundown, 'entries' | 'flatOrder'>,
selectedEventId: MaybeString,
): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = [];
for (const id of data.flatOrder) {
const entry = data.entries[id];
const extendedEntry = { ...entry, ...process(entry) };
flatRundown.push(extendedEntry);
}
return flatRundown;
}
/**
* Creates a process function which aggregates the rundown metadata and event metadata
*/
export function initRundownMetadata(selectedEventId: MaybeString) {
let rundownMeta: RundownMetadata = {
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
rundownMeta = processedRundownMetadata;
return rundownMeta;
}
return { metadata: rundownMeta, process };
}
/**
* Receives a rundown entry and processes its place in the rundown
*/
function processEntry(
rundownMetadata: RundownMetadata,
selectedEventId: MaybeString,
entry: Readonly<OntimeEntry>,
): Readonly<RundownMetadata> {
const processedData = { ...rundownMetadata };
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
processedData.previousEvent = processedData.latestEvent;
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
}
if (isOntimeGroup(entry)) {
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length;
} else {
// for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
// if the parent is not the current group, we need to update the groupId
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
processedData.groupEntries = undefined;
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) {
// if the entry has no parent, it cannot have a group colour
processedData.groupColour = undefined;
}
}
if (isOntimeEvent(entry)) {
// event indexes are 1 based in UI
processedData.eventIndex += 1;
processedData.isFirstAfterGroup = Boolean(processedData.previousEvent?.parent) && entry.parent === null;
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
}
if (isNewLatest(entry, processedData.latestEvent)) {
// this event is the forward most event in rundown, for next iteration
processedData.latestEvent = entry;
}
}
}
}
return processedData;
}
+4
View File
@@ -17,6 +17,7 @@ import {
REPORT, REPORT,
RUNDOWN, RUNDOWN,
RUNTIME, RUNTIME,
TRANSLATION,
URL_PRESETS, URL_PRESETS,
VIEW_SETTINGS, VIEW_SETTINGS,
} from '../api/constants'; } from '../api/constants';
@@ -173,6 +174,9 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings: case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS }); ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break; break;
case RefetchKey.Translation:
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</div> </div>
); );
} }
@@ -106,7 +106,6 @@ $inner-padding: 1rem;
th, th,
td { td {
padding: 0.5rem; padding: 0.5rem;
vertical-align: top;
} }
tr:nth-child(even) { tr:nth-child(even) {
@@ -6,14 +6,6 @@
width: 100%; width: 100%;
} }
.fieldForm {
padding: 1rem;
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 1rem;
}
.twoCols { .twoCols {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils';
type NewRundownFormState = {
title: string;
};
interface ManageRundownForm {
onClose: () => void;
}
export function ManageRundownForm({ onClose }: ManageRundownForm) {
const { create } = useMutateProjectRundowns();
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting },
} = useForm<NewRundownFormState>({
defaultValues: { title: '' },
});
const createRundown = async (values: NewRundownFormState) => {
try {
await create(values.title || 'untitled');
onClose();
} catch (error) {
setError('root', { message: `Failed to create rundown. ${error}` });
}
};
// give initial focus to the title field
useEffect(() => {
setFocus('title');
}, [setFocus]);
return (
<Panel.Indent as='form' 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>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Indent>
);
}
@@ -1,18 +1,59 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const [deleteOpen, deleteHandlers] = useDisclosure(); const { remove, load } = useMutateProjectRundowns();
const [loadOpen, loadHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => {
setActionError(null);
setTargetRundown(id);
loadHandlers.open();
};
const openDelete = (id: string) => {
setActionError(null);
setTargetRundown(id);
deleteHandlers.open();
};
const submitRundownLoad = async () => {
try {
await load(targetRundown);
} catch (error) {
setActionError(`Failed to load rundown. ${maybeAxiosError(error)}`);
} finally {
loadHandlers.close();
}
};
const submitRundownDelete = async () => {
try {
await remove(targetRundown);
} catch (error) {
setActionError(`Failed to delete rundown. ${maybeAxiosError(error)}`);
} finally {
deleteHandlers.close();
}
};
return ( return (
<> <>
@@ -21,49 +62,60 @@ export default function ManageRundowns() {
<Panel.SubHeader> <Panel.SubHeader>
Manage project rundowns Manage project rundowns
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={() => undefined} disabled> <Button
onClick={() => {
setActionError(null);
newHandlers.open();
}}
>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Table> <Panel.Section>
<thead> {isNewLoad && <ManageRundownForm onClose={newHandlers.close} />}
<tr> {actionError && <Panel.Error>{actionError}</Panel.Error>}
<th># Entries</th> <Panel.Table>
<th style={{ width: '100%' }}>Title</th> <thead>
<th /> <tr>
</tr> <th># Entries</th>
</thead> <th style={{ width: '100%' }}>Title</th>
<tbody> <th />
{data.rundowns.map((rundown) => { </tr>
const isLoaded = data.loaded === rundown.id; </thead>
return ( <tbody>
<tr key={rundown.id} className={cx([isLoaded && style.current])}> {data?.rundowns?.map(({ id, numEntries, title }) => {
<td>{rundown.numEntries}</td> const isLoaded = data.loaded === id;
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td> return (
<Panel.InlineElements as='td'> <tr key={id} className={cx([isLoaded && style.current])}>
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}> <td>{numEntries}</td>
Load <td>
</Button> {title} {isLoaded && <Tag>Loaded</Tag>}
<Button </td>
size='small' <Panel.InlineElements as='td'>
variant='subtle-destructive' <Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
onClick={() => deleteHandlers.open()} Load
disabled={isLoaded} </Button>
> <Button
Delete size='small'
</Button> variant='subtle-destructive'
</Panel.InlineElements> onClick={() => openDelete(id)}
</tr> disabled={isLoaded}
); >
})} Delete
</tbody> </Button>
</Panel.Table> </Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
<Dialog <Dialog
isOpen={deleteOpen} isOpen={isOpenDelete}
onClose={deleteHandlers.close} onClose={deleteHandlers.close}
title='Load rundown' title='Load rundown'
showBackdrop showBackdrop
@@ -78,16 +130,16 @@ export default function ManageRundowns() {
<Button size='large' onClick={deleteHandlers.close}> <Button size='large' onClick={deleteHandlers.close}>
Cancel Cancel
</Button> </Button>
<Button variant='destructive' size='large' onClick={() => undefined}> <Button variant='destructive' size='large' onClick={submitRundownDelete}>
Delete rundown Delete rundown
</Button> </Button>
</> </>
} }
/> />
<Dialog <Dialog
isOpen={loadOpen} isOpen={isOpenLoad}
onClose={loadHandlers.close} onClose={loadHandlers.close}
title='Delete rundown' title='Load rundown'
showBackdrop showBackdrop
showCloseButton showCloseButton
bodyElements={ bodyElements={
@@ -100,7 +152,7 @@ export default function ManageRundowns() {
<Button size='large' onClick={loadHandlers.close}> <Button size='large' onClick={loadHandlers.close}>
Cancel Cancel
</Button> </Button>
<Button variant='primary' size='large' onClick={() => undefined}> <Button variant='primary' size='large' onClick={submitRundownLoad}>
Load rundown Load rundown
</Button> </Button>
</> </>
@@ -83,11 +83,7 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined; const isEditMode = initialKey !== undefined;
return ( return (
<form <Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
onSubmit={handleSubmit(setupSubmit)}
className={style.fieldForm}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Info> <Info>
Please note that images can quickly deteriorate your app&apos;s performance. Please note that images can quickly deteriorate your app&apos;s performance.
<br /> <br />
@@ -107,7 +103,7 @@ export default function CustomFieldForm({
/> />
</div> </div>
<div className={style.twoCols}> <div className={style.twoCols}>
<div> <label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input <Input
@@ -116,7 +112,8 @@ export default function CustomFieldForm({
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => { validate: (value) => {
if (value.trim().length === 0) return 'Required field'; if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed'; if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) { if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique'; if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
} }
@@ -125,17 +122,17 @@ export default function CustomFieldForm({
})} })}
fluid fluid
/> />
</div> </label>
<div> <label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} readOnly fluid /> <Input {...register('key')} variant='ghosted' readOnly fluid />
</div> </label>
</div> </div>
<div> <label>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div> </label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'> <Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}> <Button variant='ghosted' onClick={onCancel}>
@@ -145,6 +142,6 @@ export default function CustomFieldForm({
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</form> </Panel.Indent>
); );
} }
@@ -12,6 +12,7 @@ tr .secondaryRow {
} }
.linkStartActive { .linkStartActive {
flex-shrink: 0;
color: $active-indicator; color: $active-indicator;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeEvent, isOntimeGroup, Rundown } from 'ontime-types'; import { CustomFields, isOntimeEvent, isOntimeGroup, isOntimeMilestone, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../../common/components/tag/Tag'; import Tag from '../../../../../../common/components/tag/Tag';
@@ -53,9 +53,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.order.map((entryId) => { {rundown.flatOrder.map((entryId) => {
const entry = rundown.entries[entryId]; const entry = rundown.entries[entryId];
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return ( return (
<tr key={entry.id}> <tr key={entry.id}>
<td className={style.center}> <td className={style.center}>
@@ -64,11 +65,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td /> <td /> {/** CUE */}
<td colSpan={99}>{entry.title}</td> <td>{entry.title}</td>
<td /> {/** Flag */}
<td /> {/** Time Start */}
<td /> {/** Time End */}
<td /> {/** Duration */}
<td /> {/** Warning Time */}
<td /> {/** Danger Time */}
<td /> {/** Count to end */}
<td /> {/** Skip */}
<td style={{ ...colour }}>{entry.colour}</td>
<td /> {/** Timer Type */}
<td /> {/** End Action */}
{fieldKeys.map((field) => {
let value = '';
if (field in entry.custom) {
value = entry.custom[field];
}
return <td key={field}>{value}</td>;
})}
<td className={style.center}>
<Tag>{entry.id}</Tag>
</td>
</tr> </tr>
); );
} }
if (isOntimeMilestone(entry)) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return (
<Fragment key={entry.id}>
<tr>
<td /> {/** Index */}
<td className={style.center}>
<Tag>{entry.type}</Tag>
</td>
<td className={style.nowrap}>{entry.cue}</td>
<td>{entry.title}</td>
<td /> {/** Flag */}
<td /> {/** Time Start */}
<td /> {/** Time End */}
<td /> {/** Duration */}
<td /> {/** Warning Time */}
<td /> {/** Danger Time */}
<td /> {/** Count to end */}
<td /> {/** Skip */}
<td style={{ ...colour }}>{entry.colour}</td>
<td /> {/** Timer Type */}
<td /> {/** End Action */}
{fieldKeys.map((field) => {
let value = '';
if (field in entry.custom) {
value = entry.custom[field];
}
return <td key={field}>{value}</td>;
})}
<td className={style.center}>
<Tag>{entry.id}</Tag>
</td>
</tr>
{entry.note && (
<tr>
<td colSpan={99} className={style.secondaryRow}>
Note: {entry.note}
</td>
</tr>
)}
</Fragment>
);
}
if (!isOntimeEvent(entry)) { if (!isOntimeEvent(entry)) {
return null; return null;
} }
@@ -107,14 +172,13 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td className={style.center}> <td className={style.center}>
<Tag>{entry.endAction}</Tag> <Tag>{entry.endAction}</Tag>
</td> </td>
{isOntimeEvent(entry) && {fieldKeys.map((field) => {
fieldKeys.map((field) => { let value = '';
let value = ''; if (field in entry.custom) {
if (field in entry.custom) { value = entry.custom[field];
value = entry.custom[field]; }
} return <td key={field}>{value}</td>;
return <td key={field}>{value}</td>; })}
})}
<td className={style.center}> <td className={style.center}>
<Tag>{entry.id}</Tag> <Tag>{entry.id}</Tag>
</td> </td>
@@ -58,7 +58,7 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
}; };
return ( return (
<Panel.Section <Panel.Indent
as='form' as='form'
onSubmit={handleSubmit(handleSubmitCreate)} onSubmit={handleSubmit(handleSubmitCreate)}
onKeyDown={(event) => preventEscape(event, onClose)} onKeyDown={(event) => preventEscape(event, onClose)}
@@ -76,11 +76,9 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}> <Panel.Section className={style.innerColumn}>
<label> <Panel.Description>Project title</Panel.Description>
Project title <Input fluid placeholder='Your project name' {...register('title')} />
<Input fluid placeholder='Your project name' {...register('title')} />
</label>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Indent>
); );
} }
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { lazy, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useDisclosure } from '@mantine/hooks';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings'; import { postSettings } from '../../../../common/api/settings';
@@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput'; import GeneralPinInput from './composite/GeneralPinInput';
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
export default function GeneralSettings() { export default function GeneralSettings() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
@@ -34,6 +37,8 @@ export default function GeneralSettings() {
}, },
}); });
const [isOpen, handler] = useDisclosure();
// update form if we get new data from server // update form if we get new data from server
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@@ -63,112 +68,124 @@ export default function GeneralSettings() {
const isLoading = status === 'pending'; const isLoading = status === 'pending';
return ( return (
<Panel.Section <>
as='form' <TranslationModal isOpen={isOpen} onClose={handler.close} />
onSubmit={handleSubmit(onSubmit)} <Panel.Section
onKeyDown={(event) => preventEscape(event, onReset)} as='form'
id='app-settings' onSubmit={handleSubmit(onSubmit)}
> onKeyDown={(event) => preventEscape(event, onReset)}
<Panel.Card> id='app-settings'
<Panel.SubHeader> >
General settings <Panel.Card>
<Panel.InlineElements> <Panel.SubHeader>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}> General settings
Revert to saved <Panel.InlineElements>
</Button> <Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'> Revert to saved
Save </Button>
</Button> <Button
</Panel.InlineElements> type='submit'
</Panel.SubHeader> form='app-settings'
{submitError && <Panel.Error>{submitError}</Panel.Error>} name='general-settings-submit'
<Panel.Divider /> loading={isSubmitting}
<Panel.Section> disabled={disableSubmit}
<Panel.Loader isLoading={isLoading} /> variant='primary'
<Panel.ListGroup> >
<Panel.ListItem> Save
<Panel.Field </Button>
title='Ontime server port' </Panel.InlineElements>
description={ </Panel.SubHeader>
isOntimeCloud {submitError && <Panel.Error>{submitError}</Panel.Error>}
? 'Server port disabled for Ontime Cloud' <Panel.Divider />
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)' <Panel.Section>
} <Panel.Loader isLoading={isLoading} />
error={errors.serverPort?.message} <Panel.ListGroup>
/> <Panel.ListItem>
<Input <Panel.Field
id='serverPort' title='Ontime server port'
type='number' description={
maxLength={5} isOntimeCloud
style={{ width: '75px' }} ? 'Server port disabled for Ontime Cloud'
disabled={isOntimeCloud} : 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
{...register('serverPort', { }
required: { value: true, message: 'Required field' }, error={errors.serverPort?.message}
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, />
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, <Input
pattern: { id='serverPort'
value: isOnlyNumbers, type='number'
message: 'Value should be numeric', maxLength={5}
}, style={{ width: '75px' }}
})} disabled={isOntimeCloud}
/> {...register('serverPort', {
</Panel.ListItem> required: { value: true, message: 'Required field' },
<Panel.ListItem> max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
<Panel.Field min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
title='Editor pin code' pattern: {
description='Protect the editor view with a pin code' value: isOnlyNumbers,
error={errors.editorKey?.message} message: 'Value should be numeric',
/> },
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} /> })}
</Panel.ListItem> />
<Panel.ListItem> </Panel.ListItem>
<Panel.Field <Panel.ListItem>
title='Operator pin code' <Panel.Field
description='Protect the operator and cuesheet views with a pin code' title='Editor pin code'
error={errors.operatorKey?.message} description='Protect the editor view with a pin code'
/> error={errors.editorKey?.message}
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} /> />
</Panel.ListItem> <GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
<Panel.ListItem> </Panel.ListItem>
<Panel.Field <Panel.ListItem>
title='Time format' <Panel.Field
description='Default time format to show in views 12 /24 hours' title='Operator pin code'
error={errors.timeFormat?.message} description='Protect the operator and cuesheet views with a pin code'
/> error={errors.operatorKey?.message}
<Select />
value={watch('timeFormat')} <GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })} </Panel.ListItem>
defaultValue='24' <Panel.ListItem>
options={[ <Panel.Field
{ value: '12', label: '12 hours 11:00:10 PM' }, title='Time format'
{ value: '24', label: '24 hours 23:00:10' }, description='Default time format to show in views 12 /24 hours'
]} error={errors.timeFormat?.message}
/> />
</Panel.ListItem> <Select
<Panel.ListItem> value={watch('timeFormat')}
<Panel.Field onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
title='Views language' defaultValue='24'
description='Language to be displayed in views' options={[
error={errors.language?.message} { value: '12', label: '12 hours 11:00:10 PM' },
/> { value: '24', label: '24 hours 23:00:10' },
<Select ]}
value={watch('language')} />
onValueChange={(value) => setValue('language', value, { shouldDirty: true })} </Panel.ListItem>
disabled={disableInputs} <Panel.ListItem>
defaultValue='en' <Panel.Field
options={[ title='Views language'
{ value: 'en', label: 'English' }, description='Language to be displayed in views'
{ value: 'fr', label: 'French' }, error={errors.language?.message}
{ value: 'de', label: 'German' }, />
{ value: 'it', label: 'Italian' }, <Select
{ value: 'pt', label: 'Portuguese' }, value={watch('language')}
{ value: 'es', label: 'Spanish' }, onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
]} disabled={disableInputs}
/> defaultValue='en'
</Panel.ListItem> options={[
</Panel.ListGroup> { value: 'en', label: 'English' },
</Panel.Section> { value: 'fr', label: 'French' },
</Panel.Card> { value: 'de', label: 'German' },
</Panel.Section> { value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'es', label: 'Spanish' },
{ value: 'custom', label: 'Custom' },
]}
/>
<Button onClick={handler.open}>Edit custom translation</Button>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
</>
); );
} }
@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { langEn, TranslationObject } from 'ontime-types';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import { useTranslation } from '../../../../../translation/TranslationProvider';
import * as Panel from '../../../panel-utils/PanelUtils';
interface CustomTranslationModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
const { userTranslation, postUserTranslation } = useTranslation();
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
});
return values;
}, [userTranslation]);
const {
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, errors, isValid },
setError,
} = useForm({
defaultValues,
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange',
});
const onSubmit = async (formData: Record<string, string>) => {
try {
const translationData: Record<string, string> = {};
Object.keys(formData).forEach((key) => {
translationData[toApiKey(key)] = formData[key];
});
await postUserTranslation(translationData as TranslationObject);
reset(formData);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
return (
<Modal
title='Edit custom translations'
isOpen={isOpen}
onClose={onClose}
showCloseButton
showBackdrop
bodyElements={
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='custom-translations-form'>
<Info>
Provide custom translations for the public views of Ontime. <br />
You will need to activate this in the settings by selecting &quot;Custom&quot; as the views language.
</Info>
<Panel.ListGroup>
{Object.entries(langEn).map(([key, value]) => (
<Panel.ListItem key={key}>
<Panel.Field title={value} description='' error={errors[toFormKey(key)]?.message} />
<Input
maxLength={150}
{...register(toFormKey(key), {
required: 'This field is required',
})}
placeholder={value}
/>
</Panel.ListItem>
))}
</Panel.ListGroup>
</Panel.Section>
}
footerElements={
<div>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'>
<Panel.InlineElements>
<Button size='large' onClick={onClose}>
Cancel
</Button>
<Button
variant='primary'
size='large'
type='submit'
form='custom-translations-form'
disabled={isSubmitting || !isDirty || !isValid}
loading={isSubmitting}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</div>
}
/>
);
}
function toFormKey(key: string) {
return key.replace('.', '_');
}
function toApiKey(key: string) {
return key.replace('_', '.');
}
+62 -49
View File
@@ -37,13 +37,14 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone'; import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import RundownGroup from './rundown-group/RundownGroup'; import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd'; import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils'; import { canDrop, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -53,13 +54,15 @@ const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps { interface RundownProps {
data: Rundown; data: Rundown;
rundownMetadata: RundownMetadataObject;
} }
export default function Rundown({ data }: RundownProps) { export default function Rundown({ data, rundownMetadata }: RundownProps) {
const { order, entries, id } = data; const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries)); const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState(rundownMetadata);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({ const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown // we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`, key: `rundown.${id}-editor-collapsed-groups`,
@@ -306,7 +309,8 @@ export default function Rundown({ data }: RundownProps) {
// to workaround async updates on the drag mutations // to workaround async updates on the drag mutations
useEffect(() => { useEffect(() => {
setSortableData(makeSortableList(order, entries)); setSortableData(makeSortableList(order, entries));
}, [order, entries]); setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]);
// in run mode, we follow selection // in run mode, we follow selection
useEffect(() => { useEffect(() => {
@@ -334,19 +338,24 @@ export default function Rundown({ data }: RundownProps) {
return; return;
} }
// prevent dropping a group inside another if (!active.data.current || !over.data.current) {
if (
active.data.current?.type === SupportedEntry.Group &&
!canDrop(over.data.current?.type, over.data.current?.parent)
) {
return; return;
} }
const fromIndex = active.data.current?.sortable.index; const fromIndex: number = active.data.current.sortable.index;
const toIndex = over.data.current?.sortable.index; const toIndex: number = over.data.current.sortable.index;
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
let destinationId = over.id as EntryId; let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before'; const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
// prevent dropping a group inside another
if (
isDraggingGroup &&
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
) {
return;
}
/** /**
* We need to specially handle the end-group * We need to specially handle the end-group
@@ -357,16 +366,25 @@ export default function Rundown({ data }: RundownProps) {
if (destinationId.startsWith('end-')) { if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', ''); destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation // if we are moving before the end, we use the insert operation
if (order === 'before') { if (placement === 'before') {
order = 'insert'; placement = 'insert';
} }
} else { } else {
const group = data.entries[destinationId]; const group = data.entries[destinationId];
if (isOntimeGroup(group) && order === 'after') { // if dragging into a group
if (group.entries.length === 0) order = 'insert'; if (isOntimeGroup(group) && placement === 'after') {
else { if (isDraggingGroup) {
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
// so we can safely push the dragged event after the group
destinationId = group.id;
} else if (group.entries.length === 0) {
// ... and the group is entry, we insert
destinationId = group.id;
placement = 'insert';
} else {
// otherwise we add it to before the first group child
destinationId = group.entries[0]; destinationId = group.entries[0];
order = 'before'; placement = 'before';
} }
} }
} }
@@ -377,7 +395,7 @@ export default function Rundown({ data }: RundownProps) {
setSortableData((currentEntries) => { setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex); return reorderArray(currentEntries, fromIndex, toIndex);
}); });
reorderEntry(active.id as EntryId, destinationId, order).catch((_) => { reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries); setSortableData(currentEntries);
}); });
}; };
@@ -418,11 +436,6 @@ export default function Rundown({ data }: RundownProps) {
// 1. gather presentation options // 1. gather presentation options
const isEditMode = editorMode === AppMode.Edit; const isEditMode = editorMode === AppMode.Edit;
// 2. initialise rundown metadata
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
// keep a single reference to the metadata which we override for every entry
let rundownMetadata = metadata;
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext <DndContext
@@ -440,6 +453,7 @@ export default function Rundown({ data }: RundownProps) {
if (entryId.startsWith('end-')) { if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1]; const parentId = entryId.split('end-')[1];
const isGroupCollapsed = getIsCollapsed(parentId); const isGroupCollapsed = getIsCollapsed(parentId);
const parentMetadata = metadata[parentId];
if (isGroupCollapsed) { if (isGroupCollapsed) {
return null; return null;
@@ -450,14 +464,14 @@ export default function Rundown({ data }: RundownProps) {
// and it does not cause the reassignment of the iteration id to the previous entry // and it does not cause the reassignment of the iteration id to the previous entry
return ( return (
<Fragment key={entryId}> <Fragment key={entryId}>
{isEditMode && rundownMetadata.groupEntries === 0 && ( {isEditMode && parentMetadata?.groupEntries === 0 && (
<QuickAddButtons <QuickAddButtons
previousEventId={null} previousEventId={null}
parentGroup={parentId} parentGroup={parentId}
backgroundColor={rundownMetadata.groupColour} backgroundColor={parentMetadata?.groupColour}
/> />
)} )}
<RundownGroupEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} /> <RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
</Fragment> </Fragment>
); );
} }
@@ -466,15 +480,14 @@ export default function Rundown({ data }: RundownProps) {
// this means that this can be out of sync with order until the useEffect runs // this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here // instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId]; const entry = entries[entryId];
if (!entry) return null; const entryMetadata = metadata[entryId];
if (!entry || !entryMetadata) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing // if the entry has a parent, and it is collapsed, render nothing
if ( if (
entry.type !== SupportedEntry.Group && entry.type !== SupportedEntry.Group &&
rundownMetadata.groupId !== null && entryMetadata.groupId !== null &&
getIsCollapsed(rundownMetadata.groupId) getIsCollapsed(entryMetadata.groupId)
) { ) {
return null; return null;
} }
@@ -488,7 +501,7 @@ export default function Rundown({ data }: RundownProps) {
* ie: we are inside a group, but there is no defined colour * ie: we are inside a group, but there is no defined colour
* we default to $gray-500 #9d9d9d * we default to $gray-500 #9d9d9d
*/ */
const groupColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour; const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0; const isFirst = index === 0;
const isLast = entryId === order.at(-1); const isLast = entryId === order.at(-1);
@@ -500,9 +513,8 @@ export default function Rundown({ data }: RundownProps) {
* - when adding after, we can use the group ID directly to insert at the top of the group * - when adding after, we can use the group ID directly to insert at the top of the group
*/ */
const parentIdForBefore = const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null; const parentIdForAfter = entryMetadata.groupId;
const parentIdForAfter = rundownMetadata.groupId;
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
@@ -513,7 +525,7 @@ export default function Rundown({ data }: RundownProps) {
* - if it is not the first entry (the buttons would be there) * - if it is not the first entry (the buttons would be there)
*/} */}
{isEditMode && hasCursor && !isFirst && ( {isEditMode && hasCursor && !isFirst && (
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentGroup={parentIdForBefore} /> <QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
)} )}
{isOntimeGroup(entry) ? ( {isOntimeGroup(entry) ? (
<RundownGroup <RundownGroup
@@ -525,31 +537,29 @@ export default function Rundown({ data }: RundownProps) {
) : ( ) : (
<div <div
className={style.entryWrapper} className={style.entryWrapper}
data-testid={`entry-${rundownMetadata.eventIndex}`} data-testid={`entry-${entryMetadata.eventIndex}`}
style={groupColour ? { '--user-bg': groupColour } : {}} style={groupColour ? { '--user-bg': groupColour } : {}}
> >
{isOntimeEvent(entry) && ( {isOntimeEvent(entry) && (
<div className={style.entryIndex}> <div className={style.entryIndex}>
{entry.flag && <TbFlagFilled className={style.flag} />} {entry.flag && <TbFlagFilled className={style.flag} />}
<div className={style.index}>{rundownMetadata.eventIndex}</div> <div className={style.index}>{entryMetadata.eventIndex}</div>
</div> </div>
)} )}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}> <div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry <RundownEntry
type={entry.type} type={entry.type}
isPast={rundownMetadata.isPast} isPast={entryMetadata.isPast}
eventIndex={rundownMetadata.eventIndex} eventIndex={entryMetadata.eventIndex}
data={entry} data={entry}
loaded={rundownMetadata.isLoaded} loaded={entryMetadata.isLoaded}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} isNext={isNext}
previousEntryId={rundownMetadata.previousEntryId} isNextDay={entryMetadata.isNextDay}
previousEventId={rundownMetadata.previousEvent?.id} playback={entryMetadata.isLoaded ? featureData.playback : undefined}
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
isNextDay={rundownMetadata.isNextDay} totalGap={entryMetadata.totalGap}
totalGap={rundownMetadata.totalGap} isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
/> />
</div> </div>
</div> </div>
@@ -562,13 +572,16 @@ export default function Rundown({ data }: RundownProps) {
* - if the entry is not the group header * - if the entry is not the group header
*/} */}
{isEditMode && hasCursor && !isLast && ( {isEditMode && hasCursor && !isLast && (
<QuickAddInline previousEventId={entry.id} parentGroup={parentIdForAfter} /> <QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
)} )}
</Fragment> </Fragment>
); );
})} })}
{isEditMode && ( {isEditMode && (
<QuickAddButtons previousEventId={rundownMetadata.groupId ?? rundownMetadata.thisId} parentGroup={null} /> <QuickAddButtons
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
parentGroup={null}
/>
)} )}
<div className={style.spacer} /> <div className={style.spacer} />
</div> </div>
@@ -1,9 +1,7 @@
import { useCallback } from 'react';
import { import {
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
isOntimeMilestone, isOntimeMilestone,
MaybeString,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
Playback, Playback,
@@ -12,26 +10,11 @@ import {
import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/clone'; import { cloneEvent } from '../../common/utils/clone';
import RundownDelay from './rundown-delay/RundownDelay'; import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent'; import RundownEvent from './rundown-event/RundownEvent';
import RundownMilestone from './rundown-milestone/RundownMilestone'; import RundownMilestone from './rundown-milestone/RundownMilestone';
import { useEventSelection } from './useEventSelection';
export type EventItemActions =
| 'event'
| 'event-before'
| 'delay'
| 'delay-before'
| 'group'
| 'group-before'
| 'swap'
| 'delete'
| 'clone'
| 'make-group'
| 'update';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEntry; type: SupportedEntry;
@@ -42,8 +25,6 @@ interface RundownEntryProps {
hasCursor: boolean; hasCursor: boolean;
isNext: boolean; isNext: boolean;
isNextDay: boolean; isNextDay: boolean;
previousEntryId: MaybeString;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event isRolling: boolean; // we need to know even if not related to this event
totalGap: number; totalGap: number;
@@ -56,8 +37,6 @@ export default function RundownEntry({
loaded, loaded,
hasCursor, hasCursor,
isNext, isNext,
previousEntryId,
previousEventId,
playback, playback,
isRolling, isRolling,
eventIndex, eventIndex,
@@ -65,105 +44,11 @@ export default function RundownEntry({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEntryProps) { }: RundownEntryProps) {
const { emitError } = useEmitLog(); const { addEntry } = useEntryActions();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => { const createCloneEvent = useMemoisedFn(() => {
unselect(data.id); const newEvent = cloneEvent(data as OntimeEvent);
}, [unselect, data.id]); addEntry(newEvent, { after: data.id });
const clearMultiSelection = useCallback(() => {
clearSelectedEvents();
}, [clearSelectedEvents]);
// Create / delete new events
type FieldValue = {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
};
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
switch (action) {
case 'event': {
const newEvent = { type: SupportedEntry.Event };
const options = {
after: data.id,
lastEventId: previousEventId,
};
return addEntry(newEvent, options);
}
case 'event-before': {
const newEvent = { type: SupportedEntry.Event };
const options = {
after: previousEntryId,
};
return addEntry(newEvent, options);
}
case 'delay': {
return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
}
case 'delay-before': {
return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
}
case 'group': {
return addEntry({ type: SupportedEntry.Group }, { after: data.id });
}
case 'group-before': {
return addEntry({ type: SupportedEntry.Group }, { after: previousEntryId });
}
case 'swap': {
const { value } = payload as FieldValue;
return swapEvents({ from: value as string, to: data.id });
}
case 'delete': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return deleteEntry(Array.from(selectedEvents));
}
removeOpenEvent();
return deleteEntry([data.id]);
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
break;
}
case 'make-group': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return groupEntries(Array.from(selectedEvents));
}
break;
}
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
if (field === undefined || value === undefined) {
return;
}
const newData: Partial<OntimeEvent> = { id: data.id };
// if selected events are more than one
// we need to bulk edit
if (selectedEvents.size > 1) {
const changes: Partial<OntimeEvent> = { [field]: value };
batchUpdateEvents(changes, Array.from(selectedEvents));
return;
}
if (field in data) {
// @ts-expect-error -- not sure how to type this
newData[field] = value;
return updateEntry(newData);
}
return emitError(`Unknown field: ${field}`);
}
default: {
action satisfies never;
throw new Error(`Unhandled event ${action}`);
}
}
}); });
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
@@ -198,7 +83,7 @@ export default function RundownEntry({
dayOffset={data.dayOffset} dayOffset={data.dayOffset}
totalGap={totalGap} totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
actionHandler={actionHandler} createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0} hasTriggers={data.triggers.length > 0}
/> />
); );
@@ -1,5 +1,5 @@
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import useRundown from '../../common/hooks-query/useRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import RundownHeader from './rundown-header/RundownHeader'; import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile'; import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
@@ -12,12 +12,16 @@ interface RundownWrapperProps {
} }
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) { export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
const { data, status } = useRundown(); const { data, status, rundownMetadata } = useRundownWithMetadata();
return ( return (
<div className={styles.rundownWrapper}> <div className={styles.rundownWrapper}>
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />} {isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />}
{status === 'success' && data ? <Rundown data={data} /> : <Empty text='Connecting to server' />} {status === 'success' && data && rundownMetadata ? (
<Rundown data={data} rundownMetadata={rundownMetadata} />
) : (
<Empty text='Connecting to server' />
)}
</div> </div>
); );
} }
@@ -1,293 +1,6 @@
import { EntryId, OntimeDelay, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; import { EntryId, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils'; import { makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils';
describe('makeRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeGroup,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'group',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = makeRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
});
expect(process(demoEvents['group'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['group'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['group'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: true,
groupId: 'group',
groupColour: 'red',
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
});
it('populates previousEntries in groups', () => {
const rundownStartsWithGroup = {
group: {
id: 'group',
type: SupportedEntry.Group,
colour: 'red',
entries: ['1', '2'],
} as OntimeGroup,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = makeRundownMetadata(null);
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithGroup.group.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
});
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithGroup['1'],
previousEntryId: rundownStartsWithGroup.group.id,
thisId: rundownStartsWithGroup['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
});
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
previousEvent: rundownStartsWithGroup['1'],
latestEvent: rundownStartsWithGroup['2'],
previousEntryId: rundownStartsWithGroup['1'].id,
thisId: rundownStartsWithGroup['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
});
});
});
describe('makeSortableList()', () => { describe('makeSortableList()', () => {
it('generates a list with group ends', () => { it('generates a list with group ends', () => {
@@ -327,7 +40,7 @@ describe('makeSortableList()', () => {
expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']); expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']);
}); });
it('handles a list with a with just groups', () => { it('handles a list with just groups', () => {
const order = ['group-1', 'group-2']; const order = ['group-1', 'group-2'];
const entries: RundownEntries = { const entries: RundownEntries = {
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup, 'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup,
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isOntimeEvent, isOntimeGroup, OntimeEntry } from 'ontime-types'; import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
import GroupEditor from './GroupEditor'; import GroupEditor from './GroupEditor';
import MilestoneEditor from './MilestoneEditor';
import style from './EntryEditor.module.scss'; import style from './EntryEditor.module.scss';
@@ -38,6 +39,14 @@ export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProp
); );
} }
if (isOntimeMilestone(entry)) {
return (
<div className={style.inModal} data-testid='editor-container'>
<MilestoneEditor milestone={entry} />
</div>
);
}
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
return ( return (
<div className={style.inModal} data-testid='editor-container'> <div className={style.inModal} data-testid='editor-container'>
@@ -1,10 +1,21 @@
.entryEditor { .cuesheetEditor,
.rundownEditor {
max-height: 100%; max-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto; overflow-x: auto;
} }
.rundownEditor {
// width is locked to swatch picker elements
width: calc(15 * 2rem + 13 * 0.5rem);
// we dont want a scrollbar when in the modal
.content {
overflow-y: scroll;
}
}
.content { .content {
padding-inline: 0.5rem 1.5rem; padding-inline: 0.5rem 1.5rem;
padding-bottom: 4rem; padding-bottom: 4rem;
@@ -13,7 +24,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
overflow-y: scroll;
} }
.timeSettings { .timeSettings {
@@ -52,7 +52,7 @@ export default function RundownEntryEditor() {
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
return ( return (
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor event={entry} /> <EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} /> <EventEditorFooter id={entry.id} cue={entry.cue} />
</div> </div>
@@ -61,7 +61,7 @@ export default function RundownEntryEditor() {
if (isOntimeMilestone(entry)) { if (isOntimeMilestone(entry)) {
return ( return (
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.rundownEditor} data-testid='editor-container'>
<MilestoneEditor milestone={entry} /> <MilestoneEditor milestone={entry} />
</div> </div>
); );
@@ -69,7 +69,7 @@ export default function RundownEntryEditor() {
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
return ( return (
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.rundownEditor} data-testid='editor-container'>
<GroupEditor group={entry} /> <GroupEditor group={entry} />
</div> </div>
); );
@@ -1,7 +1,7 @@
.triggerForm { .triggerForm {
padding-block: 0.5rem; padding-block: 0.5rem;
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto auto; grid-template-columns: 8rem 1fr auto 2rem;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
@@ -9,7 +9,7 @@
.trigger { .trigger {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto; grid-template-columns: 8rem 1fr 2rem;
align-items: center; align-items: center;
&:nth-child(even) { &:nth-child(even) {
@@ -155,7 +155,7 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
<div key={id} className={style.trigger}> <div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag> <Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag> <Tag>{automationTitle}</Tag>
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}> <IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</div> </div>
@@ -6,6 +6,7 @@
height: 1px; height: 1px;
background: $blue-500; background: $blue-500;
z-index: $zindex-floating;
} }
.addButton { .addButton {
@@ -9,68 +9,53 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import style from './QuickAddInline.module.scss'; import style from './QuickAddInline.module.scss';
interface QuickAddInlineProps { interface QuickAddInlineProps {
previousEventId: MaybeString; referenceEntryId: MaybeString;
parentGroup: MaybeString; parentGroup: MaybeString;
placement: 'before' | 'after';
} }
export default memo(QuickAddInline); export default memo(QuickAddInline);
function QuickAddInline({ previousEventId, parentGroup }: QuickAddInlineProps) { function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) {
const { addEntry } = useEntryActions(); const { addEntry } = useEntryActions();
const addEvent = () => { const handleAddEntry = (type: SupportedEntry) => {
addEntry( if (placement === 'before') {
{ addEntry(
type: SupportedEntry.Event, { type, parent: type !== SupportedEntry.Group ? parentGroup : null },
parent: parentGroup, {
}, before: referenceEntryId,
{ },
after: previousEventId, );
lastEventId: previousEventId, } else {
}, addEntry(
); { type, parent: type !== SupportedEntry.Group ? parentGroup : null },
}; {
lastEventId: referenceEntryId,
const addDelay = () => { after: referenceEntryId,
addEntry( },
{ type: SupportedEntry.Delay, parent: parentGroup }, );
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
const addMilestone = () => {
addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
const addGroup = () => {
if (parentGroup !== null) {
return;
} }
addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
return ( return (
<div className={style.quickAdd} data-testid='quick-add-inline'> <div className={style.quickAdd} data-testid='quick-add-inline'>
<DropdownMenu <DropdownMenu
items={[ items={[
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: addEvent }, { type: 'item', icon: IoAdd, label: 'Add Event', onClick: () => handleAddEntry(SupportedEntry.Event) },
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: addDelay }, { type: 'item', icon: IoAdd, label: 'Add Delay', onClick: () => handleAddEntry(SupportedEntry.Delay) },
{ type: 'item', icon: IoAdd, label: 'Add Milestone', onClick: addMilestone }, {
{ type: 'item', icon: IoAdd, label: 'Add Group', onClick: addGroup, disabled: parentGroup !== null }, type: 'item',
icon: IoAdd,
label: 'Add Milestone',
onClick: () => handleAddEntry(SupportedEntry.Milestone),
},
{
type: 'item',
icon: IoAdd,
label: 'Add Group',
onClick: () => handleAddEntry(SupportedEntry.Group),
disabled: parentGroup !== null,
},
]} ]}
render={<IconButton size='small' variant='primary' className={style.addButton} />} render={<IconButton size='small' variant='primary' className={style.addButton} />}
> >
@@ -12,12 +12,12 @@ import {
import { TbFlagFilled } from 'react-icons/tb'; import { TbFlagFilled } from 'react-icons/tb';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection'; import { getSelectionMode, useEventSelection } from '../useEventSelection';
@@ -56,15 +56,7 @@ interface RundownEventProps {
dayOffset: number; dayOffset: number;
totalGap: number; totalGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
actionHandler: ( createCloneEvent: () => void;
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
hasTriggers: boolean; hasTriggers: boolean;
} }
@@ -98,11 +90,13 @@ export default function RundownEvent({
dayOffset, dayOffset,
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
actionHandler,
hasTriggers, hasTriggers,
createCloneEvent,
}: RundownEventProps) { }: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection(); const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -113,37 +107,48 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Link to previous', label: 'Link to previous',
icon: IoLink, icon: IoLink,
onClick: () => onClick: () => {
actionHandler('update', { batchUpdateEvents({ linkStart: true }, Array.from(selectedEvents));
field: 'linkStart', },
value: 'true',
}),
}, },
{ {
type: 'item', type: 'item',
label: 'Unlink from previous', label: 'Unlink from previous',
icon: IoUnlink, icon: IoUnlink,
onClick: () => onClick: () => {
actionHandler('update', { batchUpdateEvents({ linkStart: false }, Array.from(selectedEvents));
field: 'linkStart', },
value: null,
}),
}, },
{ type: 'divider' }, { type: 'divider' },
{ type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('make-group') }, {
type: 'item',
label: 'Group',
icon: IoFolder,
onClick: () => {
groupEntries(Array.from(selectedEvents));
clearSelectedEvents();
},
disabled: parent !== null,
},
{ type: 'divider' }, { type: 'divider' },
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, {
type: 'item',
label: 'Delete',
icon: IoTrash,
onClick: () => {
clearSelectedEvents();
deleteEntry(Array.from(selectedEvents));
},
},
] ]
: [ : [
{ {
type: 'item', type: 'item',
label: flag ? 'Remove flag' : 'Add flag', label: flag ? 'Remove flag' : 'Add flag',
icon: TbFlagFilled, icon: TbFlagFilled,
onClick: () => onClick: () => {
actionHandler('update', { updateEntry({ id: eventId, flag: !flag });
field: 'flag', },
value: !flag,
}),
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
@@ -157,7 +162,8 @@ export default function RundownEvent({
label: `Swap this event with ${selectedEventId ?? ''}`, label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical, icon: IoSwapVertical,
onClick: () => { onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId }); if (!selectedEventId) return;
swapEvents(selectedEventId, eventId);
clearSelectedEventId(); clearSelectedEventId();
}, },
disabled: selectedEventId == null || selectedEventId === eventId, disabled: selectedEventId == null || selectedEventId === eventId,
@@ -166,10 +172,18 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Clone', label: 'Clone',
icon: IoDuplicateOutline, icon: IoDuplicateOutline,
onClick: () => actionHandler('clone'), onClick: createCloneEvent,
}, },
{ type: 'divider' }, { type: 'divider' },
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, {
type: 'item',
label: 'Delete',
icon: IoTrash,
onClick: () => {
deleteEntry([eventId]);
unselect(eventId);
},
},
], ],
); );
@@ -92,7 +92,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
}; };
const binderColours = data.colour && getAccessibleColour(data.colour); const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent); const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const [planOffset, planOffsetLabel] = (() => { const [planOffset, planOffsetLabel] = (() => {
if (data.targetDuration === null) { if (data.targetDuration === null) {
@@ -3,14 +3,13 @@
.milestone { .milestone {
@include block-styling; @include block-styling;
margin-left: calc(2rem + 1px); // binder + border
margin-block: 0.125rem; margin-block: 0.125rem;
padding-right: 0.25rem; padding-right: 0.25rem;
background-color: $gray-1050; // to override inline background-color: $gray-1050; // to override inline
color: $section-white; // to override inline color: $section-white; // to override inline
display: grid; display: grid;
grid-template-columns: 2rem 1fr 3fr; grid-template-columns: 2rem 0.4fr 1fr;
align-items: center; align-items: center;
height: $secondary-block-height; height: $secondary-block-height;
gap: 0.5rem; gap: 0.5rem;
+11 -127
View File
@@ -1,129 +1,4 @@
import { import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
EntryId,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeMilestone,
PlayableEvent,
RundownEntries,
SupportedEntry,
} from 'ontime-types';
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
type RundownMetadata = {
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
thisId: MaybeString;
eventIndex: number;
isPast: boolean;
isNextDay: boolean;
totalGap: number;
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
isLoaded: boolean;
groupId: MaybeString;
groupColour: string | undefined;
groupEntries: number | undefined;
};
/**
* Creates a process function which aggregates the rundown metadata and event metadata
*/
export function makeRundownMetadata(selectedEventId: MaybeString) {
let rundownMeta: RundownMetadata = {
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
rundownMeta = processedRundownMetadata;
return rundownMeta;
}
return { metadata: rundownMeta, process };
}
/**
* Receives a rundown entry and processes its place in the rundown
*/
function processEntry(
rundownMetadata: RundownMetadata,
selectedEventId: MaybeString,
entry: Readonly<OntimeEntry>,
): Readonly<RundownMetadata> {
const processedData = { ...rundownMetadata };
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
processedData.previousEvent = processedData.latestEvent;
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
}
if (isOntimeGroup(entry)) {
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length;
} else {
// for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
// if the parent is not the current group, we need to update the groupId
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
processedData.groupEntries = undefined;
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) {
// if the entry has no parent, it cannot have a group colour
processedData.groupColour = undefined;
}
}
if (isOntimeEvent(entry)) {
// event indexes are 1 based in UI
processedData.eventIndex += 1;
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
}
if (isNewLatest(entry, processedData.latestEvent)) {
// this event is the forward most event in rundown, for next iteration
processedData.latestEvent = entry;
}
}
}
}
return processedData;
}
/** /**
* Creates a sortable list of entries * Creates a sortable list of entries
@@ -160,14 +35,23 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
* Checks whether a drop operation is valid * Checks whether a drop operation is valid
* Currently only used for validating dropping groups * Currently only used for validating dropping groups
*/ */
export function canDrop(targetType?: SupportedEntry | 'end-group', targetParent?: EntryId | null): boolean { export function canDrop(
targetType: SupportedEntry | 'end-group',
targetParent: EntryId | null,
order?: 'after' | 'before',
isTargetCollapsed?: boolean,
): boolean {
// this would mean inserting a group inside another // this would mean inserting a group inside another
if (targetType === 'end-group') { if (targetType === 'end-group') {
return false; return false;
} }
// this means swapping places with another group // this means swapping places with another group
// !!! if the user is dragging down, they could be inserting into a group depending on whether the group is collapsed
if (targetType === 'group') { if (targetType === 'group') {
if (order !== undefined && order === 'after' && !isTargetCollapsed) {
return false;
}
return true; return true;
} }
+3 -3
View File
@@ -40,8 +40,8 @@
} }
$track-color: $white-1; $track-color: $white-1;
$thumb-color: $gray-1100; $thumb-color: $white-20;
$thumb-color-hover: $gray-900; $thumb-color-hover: $white-60;
/* Apply a natural box layout model to all elements */ /* Apply a natural box layout model to all elements */
html { html {
@@ -157,7 +157,7 @@ input[type='number'] {
/* Track */ /* Track */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: $white-1; background: $track-color;
border-radius: 2px; border-radius: 2px;
} }
@@ -1,9 +1,11 @@
import { createContext, PropsWithChildren, useCallback, useContext } from 'react'; import { createContext, PropsWithChildren, useCallback, useContext } from 'react';
import { langEn, TranslationObject } from 'ontime-types';
import { postUserTranslation } from '../common/api/assets';
import { useCustomTranslation } from '../common/hooks-query/useCustomTranslation';
import useSettings from '../common/hooks-query/useSettings'; import useSettings from '../common/hooks-query/useSettings';
import { langDe } from './languages/de'; import { langDe } from './languages/de';
import { langEn } from './languages/en';
import { langEs } from './languages/es'; import { langEs } from './languages/es';
import { langFr } from './languages/fr'; import { langFr } from './languages/fr';
import { langIt } from './languages/it'; import { langIt } from './languages/it';
@@ -21,15 +23,20 @@ const translationsList = {
export type TranslationKey = keyof typeof langEn; export type TranslationKey = keyof typeof langEn;
interface TranslationContextValue { interface TranslationContextValue {
userTranslation: TranslationObject;
getLocalizedString: (key: TranslationKey, lang?: string) => string; getLocalizedString: (key: TranslationKey, lang?: string) => string;
postUserTranslation: (translation: TranslationObject) => Promise<void>;
} }
const TranslationContext = createContext<TranslationContextValue>({ const TranslationContext = createContext<TranslationContextValue>({
userTranslation: langEn,
getLocalizedString: () => '', getLocalizedString: () => '',
postUserTranslation: async () => {},
}); });
export const TranslationProvider = ({ children }: PropsWithChildren) => { export const TranslationProvider = ({ children }: PropsWithChildren) => {
const { data } = useSettings(); const { data } = useSettings();
const { data: translationData } = useCustomTranslation();
const getLocalizedString = useCallback( const getLocalizedString = useCallback(
(key: TranslationKey, lang = data?.language || 'en'): string => { (key: TranslationKey, lang = data?.language || 'en'): string => {
@@ -37,20 +44,24 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
if (key in translationsList[lang as keyof typeof translationsList]) { if (key in translationsList[lang as keyof typeof translationsList]) {
return translationsList[lang as keyof typeof translationsList][key]; return translationsList[lang as keyof typeof translationsList][key];
} }
} else if (lang === 'custom') {
return translationData[key];
} }
return langEn[key]; return langEn[key];
}, },
[data?.language], [data?.language, translationData],
); );
const contextValue = { const contextValue = {
userTranslation: translationData,
getLocalizedString, getLocalizedString,
postUserTranslation,
}; };
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>; return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
}; };
export const useTranslation = () => { export const useTranslation = () => {
const { getLocalizedString } = useContext(TranslationContext); const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext);
return { getLocalizedString }; return { userTranslation, getLocalizedString, postUserTranslation };
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langDe: TranslationObject = { export const langDe: TranslationObject = {
'common.expected_finish': 'Erwartetes Ende', 'common.expected_finish': 'Erwartetes Ende',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langEs: TranslationObject = { export const langEs: TranslationObject = {
'common.expected_finish': 'Finalización esperada', 'common.expected_finish': 'Finalización esperada',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langFr: TranslationObject = { export const langFr: TranslationObject = {
'common.expected_finish': 'Fin estimée à', 'common.expected_finish': 'Fin estimée à',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langIt: TranslationObject = { export const langIt: TranslationObject = {
'common.expected_finish': 'Fine Prevista', 'common.expected_finish': 'Fine Prevista',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langPt: TranslationObject = { export const langPt: TranslationObject = {
'common.expected_finish': 'Término esperado', 'common.expected_finish': 'Término esperado',
@@ -18,6 +18,7 @@ import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils'; import { getOrderedSubscriptions } from './countdown.utils';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions'; import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown';
import { CountdownData, useCountdownData } from './useCountdownData'; import { CountdownData, useCountdownData } from './useCountdownData';
import './Countdown.scss'; import './Countdown.scss';
@@ -116,6 +117,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
); );
} }
if (subscribedEvents.length === 1) {
const event = subscribedEvents.at(0);
if (!event) return null;
return <SingleEventCountdown subscribedEvent={event} goToEditMode={goToEditMode} />;
}
return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />; return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />;
} }
@@ -0,0 +1,34 @@
@use '@/theme/viewerDefs' as *;
.single-container {
height: 100%;
margin-top: 5vh;
display: flex;
flex-direction: column;
gap: $view-element-gap;
}
.event__title {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
font-size: clamp(40px, 4.5vw, 80px);
line-height: 1.1em;
text-align: center;
}
.event__status {
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: clamp(2rem, 3.5vw, 3.5rem);
font-weight: 600;
text-transform: uppercase;
}
.event__timer {
color: var(--timer-color-override, $timer-color);
font-size: 15vw;
line-height: 0.9em;
text-align: center;
letter-spacing: 0.05em;
font-weight: 600;
}
@@ -0,0 +1,67 @@
import { IoPencil } from 'react-icons/io5';
import { OntimeEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { useCountdownOptions } from './countdown.options';
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils';
import './SingleEventCountdown.scss';
interface SingleEventCountdownProps {
subscribedEvent: OntimeEvent;
goToEditMode: () => void;
}
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
const showFab = useFadeOutOnInactivity(true);
return (
<div className='single-container' data-testid='countdown-event'>
<SubscriptionStatus event={subscribedEvent} />
<div className='event__title'>{subscribedEvent.title}</div>
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoPencil /> Edit
</Button>
</div>
</div>
);
}
interface SubscriptionStatusProps {
event: OntimeEvent;
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation();
const { selectedEventId } = useSelectedEventId();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showExpected } = useCountdownOptions();
const { playback, current, clock } = useCountdownSocket();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
current,
playback,
clock,
event,
selectedEventId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showExpected,
);
return (
<>
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div>
<div className='event__timer'>{timer}</div>
</>
);
}
@@ -4,7 +4,6 @@ import { useSessionStorage } from '@mantine/hooks';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { sessionScope } from '../../externals'; import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -15,7 +14,6 @@ import { useCuesheetPermissions } from './useTablePermissions';
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() { function CuesheetTableWrapper() {
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const preset = use(PresetContext); const preset = use(PresetContext);
@@ -52,15 +50,11 @@ function CuesheetTableWrapper() {
[customFields, cuesheetMode, preset], [customFields, cuesheetMode, preset],
); );
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending'; const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
{isLoading ? ( {isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
<EmptyPage text='Loading...' />
) : (
<CuesheetTable data={flatRundown} columns={columns} cuesheetMode={cuesheetMode} />
)}
</CuesheetDnd> </CuesheetDnd>
); );
} }
@@ -9,12 +9,12 @@ import {
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import useColumnManager from '../cuesheet-table/useColumnManager'; import useColumnManager from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<OntimeEntry>[]; columns: ColumnDef<ExtendedEntry>[];
} }
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) { export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
@@ -1,20 +1,22 @@
$table-font-size: 1rem; $table-font-size: 1rem;
$table-header-font-size: calc(1rem - 2px); $table-header-font-size: calc(1rem - 2px);
.cuesheetContainer {
grid-area: table;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
padding-bottom: 70vh; // allow focus to reach last elements
}
.cuesheet { .cuesheet {
font-size: $table-font-size; font-size: $table-font-size;
font-weight: 400; font-weight: 400;
color: $ui-white; color: $ui-white;
padding-bottom: 70vh; // allow focus to reach last elements
thead {
tr {
&::before {
content: '';
display: block;
width: 4px;
height: 100%;
}
}
}
tr { tr {
display: flex; display: flex;
@@ -30,10 +32,6 @@ $table-header-font-size: calc(1rem - 2px);
width: 0.5rem; width: 0.5rem;
} }
} }
&:first-of-type {
margin-left: 4px; // compensate left border
}
} }
th, th,
@@ -82,7 +80,7 @@ $table-header-font-size: calc(1rem - 2px);
.actionColumn { .actionColumn {
background-color: $gray-1250; background-color: $gray-1250;
width: calc(2rem + 1px); // button + padding + margin width: 2rem; // button + padding + margin
} }
.indexColumn { .indexColumn {
width: 3.5em; width: 3.5em;
@@ -1,15 +1,23 @@
import { memo, useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, TimeField } from 'ontime-types'; import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useFollowSelected } from '../../../common/hooks/useFollowComponent'; import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader'; import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu'; import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import useColumnManager from './useColumnManager'; import useColumnManager from './useColumnManager';
@@ -17,19 +25,20 @@ import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
interface CuesheetTableProps { interface CuesheetTableProps {
data: OntimeEntry[]; columns: ColumnDef<ExtendedEntry>[];
columns: ColumnDef<OntimeEntry>[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
} }
export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetTableProps) { export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTableProps) {
const { data, status } = useFlatRundownWithMetadata();
const { updateEntry, updateTimer } = useEntryActions(); const { updateEntry, updateTimer } = useEntryActions();
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes); const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds); const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const { selectedRef, scrollRef } = useFollowSelected(cuesheetMode === AppMode.Run); const { selectedEventId } = useSelectedEventId();
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const { listeners } = useTableNav(); const { listeners } = useTableNav();
const meta = useMemo( const meta = useMemo(
@@ -96,9 +105,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
setColumnSizing({}); setColumnSizing({});
}, [setColumnSizing]); }, [setColumnSizing]);
const headerGroups = table.getHeaderGroups(); // in run mode, we follow the selected row
const rowModel = table.getRowModel(); useEffect(() => {
const allLeafColumns = table.getAllLeafColumns(); if (cuesheetMode === AppMode.Edit || virtuosoRef.current === null || !selectedEventId) {
return;
}
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth' });
}, [cuesheetMode, data, selectedEventId]);
/** /**
* To improve performance on resizing, we memoise the column sizes * To improve performance on resizing, we memoise the column sizes
@@ -118,6 +133,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation // eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
}, [table.getState().columnSizingInfo, table.getState().columnSizing]); }, [table.getState().columnSizingInfo, table.getState().columnSizing]);
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
const isLoading = !data || status === 'pending';
if (isLoading) {
return <EmptyPage text='Loading...' />;
}
return ( return (
<> <>
<CuesheetTableSettings <CuesheetTableSettings
@@ -126,25 +150,97 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
handleResetReordering={resetColumnOrder} handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
/> />
<div className={style.cuesheetContainer} ref={scrollRef}> <TableVirtuoso
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}> ref={virtuosoRef}
<CuesheetHeader headerGroups={headerGroups} cuesheetMode={cuesheetMode} /> data={data}
{table.getState().columnSizingInfo.isResizingColumn ? ( increaseViewportBy={{ top: 100, bottom: 200 }}
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} /> components={{
) : ( EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} /> Table: ({ style: injectedStyles, ...virtuosoProps }) => {
)} return (
</table> <table
</div> className={style.cuesheet}
id='cuesheet'
style={{ ...injectedStyles, ...columnSizeVars }}
{...listeners}
{...virtuosoProps}
/>
);
},
TableRow: ({ item: _item, ...virtuosoProps }) => {
// eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
const key = row.original.id;
const entry = row.original;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={table}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return <DelayRow key={key} duration={entry.duration} {...virtuosoProps} />;
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={table}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={table}
{...virtuosoProps}
/>
);
},
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
}}
fixedHeaderContent={() => {
return table
.getHeaderGroups()
.map((headerGroup) => (
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />
));
}}
/>
<CuesheetTableMenu /> <CuesheetTableMenu />
</> </>
); );
} }
/**
* While dragging, we avoid re-rendering the body by render
*/
const MemoisedBody = memo(
CuesheetBody,
(prev, next) => prev.table.options.data === next.table.options.data,
) as typeof CuesheetBody;
@@ -1,189 +0,0 @@
import { RefObject, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { RowModel, Table } from '@tanstack/react-table';
import {
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
OntimeEntry,
OntimeGroup,
Rundown,
} from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { RUNDOWN } from '../../../../common/api/constants';
import EmptyTableBody from '../../../../common/components/state/EmptyTableBody';
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import DelayRow from './DelayRow';
import EventRow from './EventRow';
import GroupRow from './GroupRow';
import MilestoneRow from './MilestoneRow';
import { cleanup } from './rowObserver';
interface CuesheetBodyProps {
rowModel: RowModel<OntimeEntry>;
selectedRef: RefObject<HTMLTableRowElement | null>;
table: Table<OntimeEntry>;
}
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
const queryClient = useQueryClient();
const { selectedEventId } = useSelectedEventId();
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
let eventIndex = 0;
// for the first event, it will be past if there is something selected
let isPast = Boolean(selectedEventId);
let hadGroup = false;
// remove the observer when the table unmounts
useEffect(() => {
return () => {
cleanup();
};
}, []);
if (rowModel.rows.length === 0) {
return <EmptyTableBody text='No data in rundown' />;
}
return (
<tbody>
{rowModel.rows.map((row, index) => {
const key = row.original.id;
const isSelected = selectedEventId === key;
const entry = row.original;
if (isSelected) {
isPast = false;
}
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
hidePast={isPast && hidePast}
rowId={row.id}
rowIndex={row.index}
table={table}
/>
);
}
if (isOntimeDelay(entry)) {
if (isPast && hidePast) {
return null;
}
const delayVal = entry.duration;
if (hideDelays || delayVal === 0) {
return null;
}
let parentBgColour: string | null = null;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent] as OntimeGroup | undefined;
parentBgColour = parentEntry?.colour ?? null;
}
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
}
if (isOntimeMilestone(entry)) {
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (entry.colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
rowBgColour = colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
let parentBgColour: string | null = null;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
parentBgColour = (parentEntry as OntimeGroup | undefined)?.colour ?? null;
}
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={isPast}
parentBgColour={parentBgColour}
parentId={entry.parent}
rowBgColour={rowBgColour}
rowId={row.id}
rowIndex={index}
table={table}
/>
);
}
if (isOntimeEvent(entry)) {
eventIndex++;
const isSelected = key === selectedEventId;
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#087A27'; // $active-green
} else if (entry.colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
rowBgColour = colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
let parentBgColour: string | undefined;
let firstAfterGroup = false;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent] as OntimeGroup | undefined;
parentBgColour = parentEntry?.colour;
hadGroup = true;
} else if (hadGroup) {
firstAfterGroup = true;
hadGroup = false;
}
return (
<EventRow
key={row.id}
rowId={row.id}
event={entry}
eventIndex={eventIndex}
rowIndex={index}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour}
parentBgColour={parentBgColour}
table={table}
firstAfterGroup={firstAfterGroup}
/>
);
}
// currently there is no scenario where entryType is not handled above, either way...
return null;
})}
</tbody>
);
}
@@ -1,8 +1,8 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table'; import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { usePersistedCuesheetOptions } from '../../cuesheet.options';
@@ -12,54 +12,45 @@ import { SortableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeEntry>[]; headerGroup: HeaderGroup<ExtendedEntry>;
cuesheetMode: AppMode; cuesheetMode: AppMode;
} }
export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) { export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return ( return (
<thead className={style.tableHeader}> <tr key={headerGroup.id}>
{headerGroups.map((headerGroup) => { {cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
const key = headerGroup.id; {!hideIndexColumn && (
<th className={style.indexColumn} tabIndex={-1}>
#
</th>
)}
<SortableContext key={headerGroup.id} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const customBackground = header.column.columnDef.meta?.colour;
const canWrite = header.column.columnDef.meta?.canWrite;
return ( const customStyles: CSSProperties = {
<tr key={headerGroup.id}> opacity: canWrite ? 1 : 0.6,
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />} };
{!hideIndexColumn && ( if (customBackground) {
<th className={style.indexColumn} tabIndex={-1}> const customColour = getAccessibleColour(customBackground);
# customStyles.backgroundColor = customColour.backgroundColor;
</th> customStyles.color = customColour.color;
)} }
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const customBackground = header.column.columnDef.meta?.colour;
const canWrite = header.column.columnDef.meta?.canWrite;
const customStyles: CSSProperties = { return (
opacity: canWrite ? 1 : 0.6, <SortableCell
}; key={header.column.columnDef.id}
if (customBackground) { header={header}
const customColour = getAccessibleColour(customBackground); injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
customStyles.backgroundColor = customColour.backgroundColor; >
customStyles.color = customColour.color; {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
} </SortableCell>
);
return ( })}
<SortableCell </SortableContext>
key={header.column.columnDef.id} </tr>
header={header}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
);
})}
</thead>
); );
} }
@@ -1,15 +1,13 @@
@import '../CuesheetTable.module.scss'; @import '../CuesheetTable.module.scss';
.delayRow { .delayRow {
width: calc(100vw - 2rem);
color: $ontime-delay-text; color: $ontime-delay-text;
border-left: 4px solid var(--user-bg); border-left: 4px solid transparent;
td { td {
width: 100%; width: calc(100% - 4px);
padding-block: 0.5rem; padding-block: 0.5rem;
text-align: center; text-align: center;
transform: translateX(45%);
&:first-letter { &:first-letter {
text-transform: uppercase; text-transform: uppercase;
@@ -1,28 +1,26 @@
import { memo } from 'react'; import { memo } from 'react';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import style from './DelayRow.module.scss'; import style from './DelayRow.module.scss';
interface DelayRowProps { interface DelayRowProps {
duration: number; duration: number;
parentBgColour: string | null;
} }
function DelayRow({ duration, parentBgColour }: DelayRowProps) { function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
if (hideDelays || duration === 0) {
return null;
}
const delayTime = millisToDelayString(duration, 'expanded'); const delayTime = millisToDelayString(duration, 'expanded');
return ( return (
<tr <tr className={style.delayRow} data-testid='cuesheet-delay' {...virtuosoProps}>
className={style.delayRow} <td tabIndex={0}>{delayTime}</td>
style={{
'--user-bg': parentBgColour ?? 'transparent',
}}
data-testid='cuesheet-delay'
>
<td tabIndex={0} role='cell'>
{delayTime}
</td>
</tr> </tr>
); );
} }
@@ -12,7 +12,7 @@
} }
&.firstAfterGroup { &.firstAfterGroup {
margin-top: 1rem; margin-top: 2rem;
} }
&.skip { &.skip {
@@ -1,89 +1,91 @@
import { RefObject, useEffect, useRef } from 'react'; import { useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types'; import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import { observeRow, unobserveRow } from './rowObserver';
import { useVisibleRowsStore } from './visibleRowsStore';
import style from './EventRow.module.scss'; import style from './EventRow.module.scss';
interface EventRowProps { interface EventRowProps {
rowId: string; rowId: string;
event: OntimeEvent; id: EntryId;
eventIndex: number; eventIndex: number;
colour: string;
isFirstAfterGroup: boolean;
isLoaded: boolean;
isPast: boolean;
groupColour: string | undefined;
flag: boolean;
skip: boolean;
parent: EntryId | null;
rowIndex: number; rowIndex: number;
isPast?: boolean; table: Table<ExtendedEntry<OntimeEntry>>;
selectedRef?: RefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
rowBgColour?: string;
parentBgColour?: string;
table: Table<OntimeEntry>;
firstAfterGroup: boolean;
} }
export default function EventRow({ export default function EventRow({
rowId, rowId,
event, id,
eventIndex, eventIndex,
rowIndex, colour,
isFirstAfterGroup,
isLoaded,
isPast, isPast,
selectedRef, groupColour,
rowBgColour, flag,
parentBgColour, skip,
parent,
rowIndex,
table, table,
firstAfterGroup, ...virtuosoProps
}: EventRowProps) { }: EventRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit, cuesheetMode: AppMode.Edit,
hideIndexColumn: false, hideIndexColumn: false,
}; };
const ownRef = useRef<HTMLTableRowElement>(null);
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
const openMenu = useCuesheetTableMenu((store) => store.openMenu); const openMenu = useCuesheetTableMenu((store) => store.openMenu);
// register this row with the intersection observer const { color, backgroundColor } = getAccessibleColour(colour);
useEffect(() => {
const element = ownRef.current;
if (element) {
element.id = rowId;
observeRow(element);
}
return () => {
if (element) {
unobserveRow(element);
}
};
}, [rowId]);
const { color, backgroundColor } = getAccessibleColour(event.colour);
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }); const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
const rowBgColour: string | undefined = useMemo(() => {
if (isLoaded) {
return '#087A27'; // $active-green
} else if (colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
return colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
return;
}, [colour, isLoaded]);
return ( return (
<tr <tr
id={rowId} id={rowId}
className={cx([ className={cx([
style.eventRow, style.eventRow,
event.skip && style.skip, skip && style.skip,
firstAfterGroup && style.firstAfterGroup, isFirstAfterGroup && style.firstAfterGroup,
Boolean(parentBgColour) && style.hasParent, parent && style.hasParent,
])} ])}
style={{ style={{
opacity: `${isPast ? '0.2' : '1'}`, opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent', '--user-bg': groupColour ?? 'transparent',
}} }}
ref={selectedRef ?? ownRef}
data-testid='cuesheet-event' data-testid='cuesheet-event'
{...virtuosoProps}
> >
{cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'> <td className={style.actionColumn} tabIndex={-1} role='cell'>
@@ -94,7 +96,7 @@ export default function EventRow({
onClick={(e) => { onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2; const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent, event.flag); openMenu({ x: rect.x, y: yPos }, id, SupportedEntry.Event, rowIndex, parent, flag);
}} }}
> >
<IoEllipsisHorizontal /> <IoEllipsisHorizontal />
@@ -106,26 +108,24 @@ export default function EventRow({
{eventIndex} {eventIndex}
</td> </td>
)} )}
{isVisible {table
? table .getRow(rowId)
.getRow(rowId) .getVisibleCells()
.getVisibleCells() .map((cell) => {
.map((cell) => { return (
return ( <td
<td key={cell.id}
key={cell.id} style={{
style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
width: `calc(var(--col-${cell.column.id}-size) * 1px)`, backgroundColor: rowBgColour,
backgroundColor: rowBgColour, }}
}} tabIndex={-1}
tabIndex={-1} role='cell'
role='cell' >
> {flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())} </td>
</td> );
); })}
})
: null}
</tr> </tr>
); );
} }
@@ -1,7 +1,7 @@
@import '../CuesheetTable.module.scss'; @import '../CuesheetTable.module.scss';
.groupRow { .groupRow {
margin-top: 1rem; margin-top: 2em;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: start; align-items: start;
@@ -1,9 +1,9 @@
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { useCurrentGroupId } from '../../../../common/hooks/useSocket'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
@@ -12,15 +12,12 @@ import style from './GroupRow.module.scss';
interface GroupRowProps { interface GroupRowProps {
groupId: EntryId; groupId: EntryId;
colour: string; colour: string;
hidePast: boolean;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<OntimeEntry>; table: Table<ExtendedEntry>;
} }
export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, table }: GroupRowProps) { export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) {
const { currentGroupId } = useCurrentGroupId();
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit, cuesheetMode: AppMode.Edit,
hideIndexColumn: false, hideIndexColumn: false,
@@ -28,12 +25,8 @@ export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, t
const openMenu = useCuesheetTableMenu((store) => store.openMenu); const openMenu = useCuesheetTableMenu((store) => store.openMenu);
if (hidePast && !currentGroupId) {
return null;
}
return ( return (
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group'> <tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group' {...virtuosoProps}>
{cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'> <td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton <IconButton
@@ -1,7 +1,7 @@
@import "../CuesheetTable.module.scss"; @import "../CuesheetTable.module.scss";
.milestoneRow { .milestoneRow {
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%); background: color-mix(in srgb, transparent 98%, var(--user-bg, $gray-500) 2%);
border-left: 4px solid var(--user-bg, $gray-500); border-left: 4px solid var(--user-bg, $gray-500);
font-style: italic; font-style: italic;
@@ -1,9 +1,11 @@
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, enDash } from '../../../../common/utils/styleUtils'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
@@ -12,12 +14,12 @@ import style from './MilestoneRow.module.scss';
interface MilestoneRowProps { interface MilestoneRowProps {
entryId: EntryId; entryId: EntryId;
isPast: boolean; isPast: boolean;
parentBgColour: string | null; parentBgColour?: string;
parentId: EntryId | null; parentId: EntryId | null;
rowBgColour?: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<OntimeEntry>; table: Table<ExtendedEntry>;
} }
export default function MilestoneRow({ export default function MilestoneRow({
@@ -25,10 +27,11 @@ export default function MilestoneRow({
isPast, isPast,
parentBgColour, parentBgColour,
parentId, parentId,
rowBgColour, colour,
rowId, rowId,
rowIndex, rowIndex,
table, table,
...virtuosoProps
}: MilestoneRowProps) { }: MilestoneRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit, cuesheetMode: AppMode.Edit,
@@ -37,6 +40,18 @@ export default function MilestoneRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu); const openMenu = useCuesheetTableMenu((store) => store.openMenu);
let rowBgColour: string | undefined;
if (colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
rowBgColour = colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
return ( return (
<tr <tr
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])} className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
@@ -45,6 +60,7 @@ export default function MilestoneRow({
'--user-bg': parentBgColour ?? 'transparent', '--user-bg': parentBgColour ?? 'transparent',
}} }}
data-testid='cuesheet-milestone' data-testid='cuesheet-milestone'
{...virtuosoProps}
> >
{cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'> <td className={style.actionColumn} tabIndex={-1} role='cell'>
@@ -79,9 +95,9 @@ export default function MilestoneRow({
style={{ style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`, width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour, backgroundColor: rowBgColour,
opacity: canRender ? 1 : 0.4,
}} }}
tabIndex={-1} tabIndex={-1}
role='cell'
> >
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())} {canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
@@ -2,12 +2,13 @@ import { CSSProperties, ReactNode } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table'; import { Header } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface SortableCellProps { interface SortableCellProps {
header: Header<OntimeEntry, unknown>; header: Header<ExtendedEntry, unknown>;
injectedStyles: CSSProperties; injectedStyles: CSSProperties;
children: ReactNode; children: ReactNode;
} }
@@ -34,11 +35,9 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
{children} {children}
</div> </div>
<div <div
{...{ onDoubleClick={() => header.column.resetSize()}
onDoubleClick: () => header.column.resetSize(), onMouseDown={header.getResizeHandler()}
onMouseDown: header.getResizeHandler(), onTouchStart={header.getResizeHandler()}
onTouchStart: header.getResizeHandler(),
}}
className={style.resizer} className={style.resizer}
/> />
</th> </th>
@@ -1,9 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy, URLPreset } from 'ontime-types'; import { CustomFields, isOntimeDelay, isOntimeEvent, TimeStrategy, URLPreset } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
@@ -16,7 +17,7 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) { function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -55,7 +56,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, un
); );
} }
function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) { function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -95,7 +96,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unkn
); );
} }
function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) { function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -126,7 +127,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry,
); );
} }
function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) { function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -135,8 +136,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
); );
// not all entries have all properties (eg groups) // not all entries have all properties (eg groups)
const initialValue = row.original[column.id as keyof OntimeEntry]; const initialValue = row.original[column.id as keyof ExtendedEntry];
if (initialValue === undefined) { if (typeof initialValue !== 'string') {
return null; return null;
} }
@@ -148,7 +149,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
} }
function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) { function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -166,7 +167,7 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />; return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
} }
function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -175,8 +176,8 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
); );
// not all entries have all properties (eg groups) // not all entries have all properties (eg groups)
const initialValue = row.original[column.id as keyof OntimeEntry]; const initialValue = row.original[column.id as keyof ExtendedEntry];
if (initialValue === undefined) { if (typeof initialValue !== 'string') {
return null; return null;
} }
@@ -188,7 +189,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />; return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
} }
function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) { function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
const event = row.original; const event = row.original;
if (!isOntimeEvent(event) || !event.flag) { if (!isOntimeEvent(event) || !event.flag) {
return null; return null;
@@ -196,7 +197,7 @@ function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
return <FlagCell />; return <FlagCell />;
} }
function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknown>) { function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -229,8 +230,8 @@ export function makeCuesheetColumns(
customFields: CustomFields, customFields: CustomFields,
cuesheetMode: AppMode, cuesheetMode: AppMode,
preset: URLPreset | undefined, preset: URLPreset | undefined,
): ColumnDef<OntimeEntry>[] { ): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<OntimeEntry>[] = []; const columnsDef: ColumnDef<ExtendedEntry>[] = [];
const modeAllowsWrite = cuesheetMode === AppMode.Edit; const modeAllowsWrite = cuesheetMode === AppMode.Edit;
const fullRead = preset ? preset.options?.read === 'full' : true; const fullRead = preset ? preset.options?.read === 'full' : true;
const fullWrite = preset ? preset.options?.write === 'full' : true; const fullWrite = preset ? preset.options?.write === 'full' : true;
@@ -6,13 +6,13 @@ import { ToggleGroup } from '@base-ui-components/react/toggle-group';
import { Toolbar } from '@base-ui-components/react/toolbar'; import { Toolbar } from '@base-ui-components/react/toolbar';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import type { Column } from '@tanstack/react-table'; import type { Column } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import { PresetContext } from '../../../../common/context/PresetContext'; import { PresetContext } from '../../../../common/context/PresetContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig'; import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { usePersistedCuesheetOptions } from '../../cuesheet.options';
@@ -23,7 +23,7 @@ import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss'; import style from './CuesheetTableSettings.module.scss';
interface CuesheetTableSettingsProps { interface CuesheetTableSettingsProps {
columns: Column<OntimeEntry, unknown>[]; columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
@@ -106,13 +106,6 @@ function ViewSettings() {
/> />
Hide seconds in table Hide seconds in table
</Editor.Label> </Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hidePast}
onCheckedChange={(checked) => options.setOption('hidePast', checked)}
/>
Hide past events
</Editor.Label>
<Editor.Label className={style.option}> <Editor.Label className={style.option}>
<Checkbox <Checkbox
defaultChecked={options.hideIndexColumn} defaultChecked={options.hideIndexColumn}
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import { debounce } from '../../../common/utils/debounce'; import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage'; import { makeStageKey } from '../../../common/utils/localStorage';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
const tableSizesKey = makeStageKey('cuesheet-sizes'); const tableSizesKey = makeStageKey('cuesheet-sizes');
const tableHiddenKey = makeStageKey('cuesheet-hidden'); const tableHiddenKey = makeStageKey('cuesheet-hidden');
@@ -14,7 +14,7 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
localStorage.setItem(tableSizesKey, JSON.stringify(sizes)); localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
}, 500); }, 500);
export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) { export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey, key: tableHiddenKey,
defaultValue: {}, defaultValue: {},
@@ -4,7 +4,6 @@ import { persist } from 'zustand/middleware';
type OptionValues = { type OptionValues = {
hideTableSeconds: boolean; hideTableSeconds: boolean;
hidePast: boolean;
hideIndexColumn: boolean; hideIndexColumn: boolean;
showDelayedTimes: boolean; showDelayedTimes: boolean;
hideDelays: boolean; hideDelays: boolean;
@@ -12,7 +11,6 @@ type OptionValues = {
const defaultOptions: OptionValues = { const defaultOptions: OptionValues = {
hideTableSeconds: false, hideTableSeconds: false,
hidePast: false,
hideIndexColumn: false, hideIndexColumn: false,
showDelayedTimes: false, showDelayedTimes: false,
hideDelays: false, hideDelays: false,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.0.0-alpha.4", "version": "4.0.0-alpha.5",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.0.0-alpha.4", "version": "4.0.0-alpha.5",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -52,7 +52,7 @@
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts", "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "tsx ./scripts/bundleCss.ts", "prebuild": "tsx ./scripts/bundleCss.ts && tsx ./scripts/bundleTranslation.ts",
"build": "node esbuild.electron.js", "build": "node esbuild.electron.js",
"build:electron": "node esbuild.electron.js", "build:electron": "node esbuild.electron.js",
"build:local": "node esbuild.dev.js", "build:local": "node esbuild.dev.js",
+24
View File
@@ -0,0 +1,24 @@
import { existsSync } from 'fs';
import { writeFile } from 'node:fs/promises';
import { defaultTranslation } from '../src/user/translations/bundledTranslation';
import path from 'path';
/**
* Script to write contents of default translation to translation.json
*/
async function bundleTranslation() {
try {
const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations');
const translationsFile = path.resolve(translationDir, 'translations.json');
if (!existsSync(translationsFile)) {
throw new Error('File does not exist');
}
await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' });
} catch (error) {
console.error('Failed writing to translations file: ', error);
}
}
bundleTranslation();
+2 -2
View File
@@ -85,7 +85,7 @@ class SocketServer implements IAdapter {
}); });
this.lastConnection = new Date(); this.lastConnection = new Date();
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`); logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
sendPacket(MessageTag.ClientInit, { clientId, clientName }); sendPacket(MessageTag.ClientInit, { clientId, clientName });
@@ -98,7 +98,7 @@ class SocketServer implements IAdapter {
ws.on('close', () => { ws.on('close', () => {
this.clients.delete(clientId); this.clients.delete(clientId);
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`); logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
this.sendClientList(); this.sendClientList();
}); });
@@ -1,10 +1,11 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types'; import { RefetchKey, type ErrorResponse } from 'ontime-types';
import { validatePostCss } from './assets.validation.js'; import { validatePostCss, validatePostTranslation } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js'; import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js'; import { defaultCss } from '../../user/styles/bundledCss.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
export const router = express.Router(); export const router = express.Router();
@@ -38,3 +39,21 @@ router.post('/css/restore', async (_req: Request, res: Response<string | ErrorRe
res.status(500).send({ message }); res.status(500).send({ message });
} }
}); });
router.post('/translations', validatePostTranslation, async (req: Request, res: Response<never | ErrorResponse>) => {
const { translation } = req.body;
if (!translation) {
res.status(400).send({ message: 'translation payload is required ' });
return;
}
try {
await writeUserTranslation(translation);
sendRefetch(RefetchKey.Translation);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
@@ -1,6 +1,9 @@
import { publicFiles } from '../../setup/index.js'; import type { TranslationObject } from 'ontime-types';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { publicFiles } from '../../setup/index.js';
import { defaultCss } from '../../user/styles/bundledCss.js'; import { defaultCss } from '../../user/styles/bundledCss.js';
/** /**
@@ -31,3 +34,13 @@ export async function writeCssFile(css: string) {
await writeFile(path, css, { encoding: 'utf8' }); await writeFile(path, css, { encoding: 'utf8' });
} }
/**
* Writes the user's custom translation file
* @param translations the updated translations to write to file
*/
export async function writeUserTranslation(translations: TranslationObject) {
const path = publicFiles.translationsFile;
const translationsString = JSON.stringify(translations, null, 2);
await writeFile(path, translationsString, { encoding: 'utf8' });
}
@@ -1,4 +1,14 @@
import { body } from 'express-validator'; import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction]; export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
export const validatePostTranslation = [
body('translation')
.custom((v) => v != null && typeof v === 'object' && !Array.isArray(v))
.withMessage('translation must be an object (key -> string)')
.bail(),
body('translation.*').isString().trim().notEmpty(),
requestValidationFunction,
];
@@ -4,6 +4,8 @@ import { getErrorMessage } from 'ontime-utils';
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js'; import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js'; import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
@@ -11,37 +13,52 @@ import { validateCustomField, validateDeleteCustomField, validateEditCustomField
export const router = express.Router(); export const router = express.Router();
/**
* Gets all the custom fields for the project
*/
router.get('/', async (_req: Request, res: Response<CustomFields>) => { router.get('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields(); const customFields = getProjectCustomFields();
res.status(200).json(customFields); res.status(200).json(customFields);
}); });
/**
* Creates a new custom field
*/
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => { router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try { try {
const newFields = await createCustomField(req.body as CustomField); const newFields = await createCustomField(req.body as CustomField);
res.status(201).send(newFields); res.status(201).json(newFields);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(400).send({ message }); res.status(400).send({ message });
} }
}); });
/**
* Modifies the properties of an existing custom field
*/
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => { router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try { try {
const currentKey = req.params.key; const currentKey = req.params.key;
const { colour, type, label } = req.body; const { colour, type, label } = req.body;
const newFields = await editCustomField(currentKey, { label, colour, type });
res.status(200).send(newFields); const projectRundowns = getDataProvider().getProjectRundowns();
const newFields = await editCustomField(currentKey, { label, colour, type }, projectRundowns);
res.status(200).json(newFields);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(400).send({ message }); res.status(400).send({ message });
} }
}); });
/**
* Deletes an existing custom field
*/
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => { router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try { try {
const customFields = await deleteCustomField(req.params.key); const projectRundowns = getDataProvider().getProjectRundowns();
res.status(200).send(customFields); const customFields = await deleteCustomField(req.params.key, projectRundowns);
res.status(200).json(customFields);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(400).send({ message }); res.status(400).send({ message });
-1
View File
@@ -26,7 +26,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
errors: ParsingError[]; errors: ParsingError[];
migrated: boolean; migrated: boolean;
} { } {
//TODO: TEST THIS!!!!!!!
let migrated = false; let migrated = false;
let migratedData = jsonData; let migratedData = jsonData;
if (v3.shouldUseThisMigration(jsonData)) { if (v3.shouldUseThisMigration(jsonData)) {
@@ -1,7 +1,7 @@
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types'; import { CustomFields, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getCustomFieldData, parseExcel } from '../excel.parser.js'; import { parseExcel } from '../excel.parser.js';
import { dataFromExcelTemplate } from './mockData.js'; import { dataFromExcelTemplate } from './mockData.js';
@@ -156,11 +156,38 @@ describe('parseExcel()', () => {
expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth'); expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth');
}); });
it('imports groups', () => { it('imports group', () => {
const testdata = [
['Title', 'Timer type', 'duration'],
['a group', 'group', '10m'],
['an event', 'clock', '1m'],
];
const importMap = {
title: 'title',
timerType: 'timer type',
duration: 'duration',
} as ImportMap;
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstGroup = result.rundown.entries[result.rundown.order[0]];
expect(result.rundown.order.length).toBe(1);
expect(result.rundown.flatOrder.length).toBe(2);
expect((firstGroup as OntimeGroup).type).toBe(SupportedEntry.Group);
expect((firstGroup as OntimeGroup).targetDuration).toBe(10 * MILLIS_PER_MINUTE);
});
it('places event between groups inside the group', () => {
const testdata = [ const testdata = [
['Title', 'Timer type'], ['Title', 'Timer type'],
['a group', 'group'], ['a group', 'group'],
['an event', 'clock'], ['an event', 'clock'],
['an event', 'clock'],
['an event', 'clock'],
['a second group ', 'group'],
['an event', 'clock'],
['an event', 'clock'],
]; ];
const importMap = { const importMap = {
@@ -168,10 +195,17 @@ describe('parseExcel()', () => {
timerType: 'timer type', timerType: 'timer type',
}; };
const result = parseExcel(testdata, {}, 'testSheet', importMap); const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]]; const firstGroup = result.rundown.entries[result.rundown.order[0]] as OntimeGroup;
const secondGroup = result.rundown.entries[result.rundown.order[1]] as OntimeGroup;
expect(result.rundown.order.length).toBe(2); expect(result.rundown.order.length).toBe(2);
expect((firstEvent as OntimeEvent).type).toBe(SupportedEntry.Group); expect(result.rundown.flatOrder.length).toBe(7);
expect(firstGroup.type).toBe(SupportedEntry.Group);
expect(firstGroup.entries.length).toBe(3);
expect(secondGroup.type).toBe(SupportedEntry.Group);
expect(secondGroup.entries.length).toBe(2);
}); });
it('imports as events if there is no timer type column', () => { it('imports as events if there is no timer type column', () => {
@@ -314,8 +348,10 @@ describe('parseExcel()', () => {
}; };
const result = parseExcel(testData, {}, 'testSheet', importMap); const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(6); expect(result.rundown.order.length).toBe(5);
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']); expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP']);
expect(result.rundown.flatOrder.length).toBe(6);
expect(result.rundown.flatOrder).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']);
expect(result.rundown.entries).toMatchObject({ expect(result.rundown.entries).toMatchObject({
A: { A: {
@@ -417,162 +453,41 @@ describe('parseExcel()', () => {
linkStart: true, linkStart: true,
}); });
}); });
});
describe('getCustomFieldData()', () => { it('handles milestones', () => {
it('generates a list of keys from the given import map', () => { const testdata = [
['Title', 'type', 'notes'],
['event...', 'count-down', ''],
['also event...', 'count-down', ''],
['this i a milestone', 'milestone', 'milestone note'],
];
const importMap = { const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
flag: 'flag',
cue: 'cue',
title: 'title', title: 'title',
countToEnd: 'count to end', timerType: 'type',
skip: 'skip',
note: 'notes', note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap; } as ImportMap;
const result = getCustomFieldData(importMap, {}); const result = parseExcel(testdata, {}, 'testSheet', importMap);
expect(result.mergedCustomFields).toStrictEqual({ const firstEvent = result.rundown.entries[result.rundown.order[0]];
lighting: { const secondEvent = result.rundown.entries[result.rundown.order[1]];
type: 'text', const milestone = result.rundown.entries[result.rundown.order[2]];
colour: '',
label: 'lighting', expect(result.rundown.order.length).toBe(3);
}, expect(firstEvent).toMatchObject({
sound: { type: SupportedEntry.Event,
type: 'text', timerType: TimerType.CountDown,
colour: '',
label: 'sound',
},
video: {
type: 'text',
colour: '',
label: 'video',
},
}); });
// it is an inverted record of <importKey, ontimeKey> expect(secondEvent).toMatchObject({
expect(result.customFieldImportKeys).toStrictEqual({ type: SupportedEntry.Event,
lx: 'lighting', timerType: TimerType.CountDown,
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
flag: 'flag',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
'ontime key': 'excel label',
},
entryId: 'id',
} as ImportMap;
const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'text', colour: 'red' },
sound: { label: 'sound', type: 'text', colour: 'green' },
ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' },
};
const result = getCustomFieldData(importMap, existingCustomFields);
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'text',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'text',
colour: 'green',
label: 'sound',
},
video: {
type: 'text',
colour: '',
label: 'video',
},
ontime_key: {
type: 'text',
colour: 'blue',
label: 'ontime key',
},
}); });
// it is an inverted record of <importKey, ontimeKey> expect(milestone).toMatchObject({
expect(result.customFieldImportKeys).toStrictEqual({ type: SupportedEntry.Milestone,
lx: 'lighting', title: 'this i a milestone',
sound: 'sound', note: 'milestone note',
av: 'video',
'excel label': 'ontime_key',
});
});
it('lowercases the keys in the import map', () => {
const importMap: ImportMap = {
...defaultImportMap,
custom: {
Lighting: 'Lx',
Sound: 'sound',
video: 'av',
},
};
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
Lighting: {
type: 'text',
colour: '',
label: 'Lighting',
},
Sound: {
type: 'text',
colour: '',
label: 'Sound',
},
video: {
type: 'text',
colour: '',
label: 'video',
},
});
// notice that the keys excel keys are lowercased
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'Lighting',
sound: 'Sound',
av: 'video',
}); });
}); });
}); });
@@ -0,0 +1,162 @@
import { CustomFields } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { getCustomFieldData } from '../excel.utils.js';
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
flag: 'flag',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'text',
colour: '',
label: 'lighting',
},
sound: {
type: 'text',
colour: '',
label: 'sound',
},
video: {
type: 'text',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
flag: 'flag',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
'ontime key': 'excel label',
},
entryId: 'id',
} as ImportMap;
const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'text', colour: 'red' },
sound: { label: 'sound', type: 'text', colour: 'green' },
ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' },
};
const result = getCustomFieldData(importMap, existingCustomFields);
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'text',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'text',
colour: 'green',
label: 'sound',
},
video: {
type: 'text',
colour: '',
label: 'video',
},
ontime_key: {
type: 'text',
colour: 'blue',
label: 'ontime key',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
'excel label': 'ontime_key',
});
});
it('lowercases the keys in the import map', () => {
const importMap: ImportMap = {
...defaultImportMap,
custom: {
Lighting: 'Lx',
Sound: 'sound',
video: 'av',
},
};
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
Lighting: {
type: 'text',
colour: '',
label: 'Lighting',
},
Sound: {
type: 'text',
colour: '',
label: 'Sound',
},
video: {
type: 'text',
colour: '',
label: 'video',
},
});
// notice that the keys excel keys are lowercased
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'Lighting',
sound: 'Sound',
av: 'video',
});
});
});
+99 -200
View File
@@ -7,7 +7,9 @@ import {
SupportedEntry, SupportedEntry,
isOntimeGroup, isOntimeGroup,
TimerType, TimerType,
CustomFieldKey, OntimeMilestone,
OntimeEntry,
isOntimeMilestone,
} from 'ontime-types'; } from 'ontime-types';
import { import {
ImportMap, ImportMap,
@@ -16,22 +18,26 @@ import {
isKnownTimerType, isKnownTimerType,
validateTimerType, validateTimerType,
validateEndAction, validateEndAction,
customFieldLabelToKey,
checkRegex,
} from 'ontime-utils'; } from 'ontime-utils';
import { Merge } from 'ts-essentials'; import { Prettify } from 'ts-essentials';
import { is } from '../../utils/is.js'; import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.js'; import { makeString } from '../../utils/parserUtils.js';
import { parseExcelDate } from '../../utils/time.js'; import { parseExcelDate } from '../../utils/time.js';
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
type MergedOntimeEntry = Prettify<
Omit<Omit<Omit<OntimeEvent, keyof OntimeGroup> & OntimeGroup, keyof OntimeMilestone> & OntimeMilestone, 'type'> & {
type: SupportedEntry | 'group-end';
}
>;
/** /**
* @description Excel array parser * @description Excel array parser
* @param {array} excelData - array with excel sheet * @param {array} excelData - array with excel sheet
* @param {ImportOptions} options - an object that contains the import map * @param {ImportOptions} options - an object that contains the import map
* @returns {object} - parsed object * @returns {object} - parsed object
* TODO: import milestones
*/ */
export const parseExcel = ( export const parseExcel = (
excelData: unknown[][], excelData: unknown[][],
@@ -41,9 +47,8 @@ export const parseExcel = (
): { ): {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
rundownMetadata: Record<string, { row: number; col: number }>; sheetMetadata: SheetMetadata;
} => { } => {
const rundownMetadata: Record<string, { row: number; col: number }> = {};
const importMap: ImportMap = { ...defaultImportMap, ...options }; const importMap: ImportMap = { ...defaultImportMap, ...options };
for (const [key, value] of Object.entries(importMap)) { for (const [key, value] of Object.entries(importMap)) {
@@ -63,166 +68,74 @@ export const parseExcel = (
revision: 0, revision: 0,
}; };
// title stuff: strings // for placing entries into groups
let titleIndex: number | null = null; let currentGroupId: string | null = null;
let cueIndex: number | null = null; const groupEntries: string[] = [];
let notesIndex: number | null = null; const { handlers, indexMap, sheetMetadata } = generateImportHandlers(importMap);
let colourIndex: number | null = null;
// options: booleans
let flagIndex: number | null = null;
let skipIndex: number | null = null;
let countToEndIndex: number | null = null;
let linkStartIndex: number | null = null;
// times: numbers
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
let durationIndex: number | null = null;
let timeWarningIndex: number | null = null;
let timeDangerIndex: number | null = null;
// options: enum properties
let endActionIndex: number | null = null;
let timerTypeIndex: number | null = null;
//ID
let entryIdIndex: number | null = null;
// record of column index and the name of the field
const customFieldIndexes: Record<number, string> = {};
excelData.forEach((row, rowIndex) => { excelData.forEach((row, rowIndex) => {
if (row.length === 0) { if (row.length === 0) {
return; return;
} }
// TODO: extract generating handlers from importMap const entry: Partial<MergedOntimeEntry> = {};
const handlers = {
[importMap.timeStart]: (row: number, col: number) => {
timeStartIndex = col;
rundownMetadata['timeStart'] = { row, col };
},
[importMap.linkStart]: (row: number, col: number) => {
linkStartIndex = col;
rundownMetadata['linkStart'] = { row, col };
},
[importMap.timeEnd]: (row: number, col: number) => {
timeEndIndex = col;
rundownMetadata['timeEnd'] = { row, col };
},
[importMap.duration]: (row: number, col: number) => {
durationIndex = col;
rundownMetadata['duration'] = { row, col };
},
[importMap.cue]: (row: number, col: number) => {
cueIndex = col;
rundownMetadata['cue'] = { row, col };
},
[importMap.title]: (row: number, col: number) => {
titleIndex = col;
rundownMetadata['title'] = { row, col };
},
[importMap.flag]: (row: number, col: number) => {
flagIndex = col;
rundownMetadata['flag'] = { row, col };
},
[importMap.countToEnd]: (row: number, col: number) => {
countToEndIndex = col;
rundownMetadata['countToEnd'] = { row, col };
},
[importMap.skip]: (row: number, col: number) => {
skipIndex = col;
rundownMetadata['skip'] = { row, col };
},
[importMap.note]: (row: number, col: number) => {
notesIndex = col;
rundownMetadata['note'] = { row, col };
},
[importMap.colour]: (row: number, col: number) => {
colourIndex = col;
rundownMetadata['colour'] = { row, col };
},
[importMap.endAction]: (row: number, col: number) => {
endActionIndex = col;
rundownMetadata['endAction'] = { row, col };
},
[importMap.timerType]: (row: number, col: number) => {
timerTypeIndex = col;
rundownMetadata['timerType'] = { row, col };
},
[importMap.timeWarning]: (row: number, col: number) => {
timeWarningIndex = col;
rundownMetadata['timeWarning'] = { row, col };
},
[importMap.timeDanger]: (row: number, col: number) => {
timeDangerIndex = col;
rundownMetadata['timeDanger'] = { row, col };
},
[importMap.entryId]: (row: number, col: number) => {
entryIdIndex = col;
rundownMetadata['id'] = { row, col };
},
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
customFieldIndexes[col] = columnText;
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
},
} as const;
const entry: Partial<Merge<OntimeEvent, OntimeGroup>> = {};
const entryCustomFields: EntryCustomFields = {}; const entryCustomFields: EntryCustomFields = {};
for (let j = 0; j < row.length; j++) { for (let j = 0; j < row.length; j++) {
const column = row[j]; const column = row[j];
// 1. we check if we have set a flag for a known field // 1. we check if we have set a flag for a known field
if (j === timerTypeIndex) { if (j === indexMap.timerType) {
const maybeTimeType = makeString(column, ''); const maybeTimeType = makeString(column, '').toLowerCase();
if (maybeTimeType === 'group') { if (maybeTimeType === 'group' || maybeTimeType === 'group-start') {
// we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Group; entry.type = SupportedEntry.Group;
entry.entries = []; entry.entries = [];
} else if (maybeTimeType === 'group-end') {
entry.type = 'group-end';
} else if (maybeTimeType === 'milestone') {
entry.type = SupportedEntry.Milestone;
} else if (maybeTimeType === 'skip-import') {
// intentional skip
return;
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) { } else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
// @ts-expect-error -- we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Event; entry.type = SupportedEntry.Event;
entry.timerType = validateTimerType(maybeTimeType); entry.timerType = validateTimerType(maybeTimeType);
} else { } else {
// if it is not a group or a known type, we dont import it // if it is not a group or a known type, we dont import it
return; return;
} }
} else if (j === titleIndex) { } else if (j === indexMap.title) {
entry.title = makeString(column, ''); entry.title = makeString(column, '');
} else if (j === timeStartIndex) { } else if (j === indexMap.timeStart) {
entry.timeStart = parseExcelDate(column); entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) { } else if (j === indexMap.linkStart) {
entry.linkStart = parseBooleanString(column); entry.linkStart = parseBooleanString(column);
} else if (j === timeEndIndex) { } else if (j === indexMap.timeEnd) {
entry.timeEnd = parseExcelDate(column); entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) { } else if (j === indexMap.duration) {
entry.duration = parseExcelDate(column); entry.duration = parseExcelDate(column);
} else if (j === cueIndex) { } else if (j === indexMap.cue) {
entry.cue = makeString(column, ''); entry.cue = makeString(column, '');
} else if (j === flagIndex) { } else if (j === indexMap.flag) {
entry.flag = parseBooleanString(column); entry.flag = parseBooleanString(column);
} else if (j === countToEndIndex) { } else if (j === indexMap.countToEnd) {
entry.countToEnd = parseBooleanString(column); entry.countToEnd = parseBooleanString(column);
} else if (j === skipIndex) { } else if (j === indexMap.skip) {
entry.skip = parseBooleanString(column); entry.skip = parseBooleanString(column);
} else if (j === notesIndex) { } else if (j === indexMap.note) {
entry.note = makeString(column, ''); entry.note = makeString(column, '');
} else if (j === endActionIndex) { } else if (j === indexMap.endAction) {
entry.endAction = validateEndAction(column); entry.endAction = validateEndAction(column);
} else if (j === timeWarningIndex) { } else if (j === indexMap.timeWarning) {
entry.timeWarning = parseExcelDate(column); entry.timeWarning = parseExcelDate(column);
} else if (j === timeDangerIndex) { } else if (j === indexMap.timeDanger) {
entry.timeDanger = parseExcelDate(column); entry.timeDanger = parseExcelDate(column);
} else if (j === colourIndex) { } else if (j === indexMap.colour) {
entry.colour = makeString(column, ''); entry.colour = makeString(column, '');
} else if (j === entryIdIndex) { } else if (j === indexMap.entryId) {
entry.id = encodeURIComponent(makeString(column, undefined)); entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in customFieldIndexes) { } else if (j in indexMap.custom) {
const importKey = customFieldIndexes[j]; const importKey = indexMap.custom[j];
const ontimeKey = customFieldImportKeys[importKey]; const ontimeKey = customFieldImportKeys[importKey];
entryCustomFields[ontimeKey] = makeString(column, ''); entryCustomFields[ontimeKey] = makeString(column, '');
} else { } else {
@@ -259,92 +172,78 @@ export const parseExcel = (
} }
const id = entry.id || generateId(); const id = entry.id || generateId();
// from excel, we can only get groups, milestones and events
if (isOntimeGroup(entry)) { if (entry.type === 'group-end') {
const group: OntimeGroup = { ...entry, custom: { ...entryCustomFields } }; if (currentGroupId) {
rundown.order.push(id); (rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
rundown.entries[id] = group; currentGroupId = null;
}
return; return;
} }
// from excel, we can only get groups, milestones and events
if (isOntimeGroup(entry as OntimeEntry)) {
const group = {
...entry,
targetDuration: entry.duration ? entry.duration : null,
custom: { ...entryCustomFields },
} as OntimeGroup;
rundown.entries[id] = group;
if (currentGroupId) {
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
}
rundown.order.push(id);
rundown.flatOrder.push(id);
currentGroupId = id;
return;
}
if (isOntimeMilestone(entry as OntimeEntry)) {
const milestone = { ...entry, custom: { ...entryCustomFields } } as OntimeMilestone;
if (currentGroupId) {
groupEntries.push(id);
milestone.parent = currentGroupId;
} else {
rundown.order.push(id);
}
rundown.flatOrder.push(id);
rundown.entries[id] = milestone;
return;
}
//and fall through to treat it as an event
const event = { const event = {
...entry, ...entry,
custom: { ...entryCustomFields }, custom: { ...entryCustomFields },
type: SupportedEntry.Event, type: SupportedEntry.Event,
} as OntimeEvent; } as OntimeEvent;
if (timerTypeIndex === null) { if (indexMap.timerType === null) {
event.timerType = TimerType.CountDown; event.timerType = TimerType.CountDown;
} }
rundown.order.push(id);
if (entry.linkStart === undefined) {
event.linkStart = true;
}
if (currentGroupId) {
groupEntries.push(id);
event.parent = currentGroupId;
} else {
rundown.order.push(id);
}
rundown.flatOrder.push(id); rundown.flatOrder.push(id);
rundown.entries[id] = event; rundown.entries[id] = event;
}); });
if (currentGroupId) {
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
}
return { return {
rundown, rundown,
customFields: mergedCustomFields, customFields: mergedCustomFields,
rundownMetadata, sheetMetadata,
}; };
}; };
/**
* Utility function infers a boolean from a string value
*/
function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
// falsy values would be nullish or empty string
if (!value || typeof value !== 'string') {
return false;
}
return value.toLowerCase() !== 'false';
}
/**
* Receives an import map which contains custom field labels and a custom fields object
* the result importkeys is an inverted record of <importKey, ontimeKey>
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
* @returns the new custom fields, and a map of excel column names to ontime keys
* @private exported for testing
*/
export function getCustomFieldData(
importMap: ImportMap,
existingCustomFields: CustomFields,
): {
mergedCustomFields: CustomFields;
customFieldImportKeys: Record<keyof CustomFields, string>;
} {
const mergedCustomFields: CustomFields = {};
/**
* A map of import keys to ontime keys
* Map<excel column name, ontime key>
*/
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
for (const ontimeLabel in importMap.custom) {
// if the label is not valid, we skip the import
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
continue;
}
// generate a key for the custom field
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
// we lower case the excel key to make it easier to match
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
// 1. add the custom field to the merged custom fields
mergedCustomFields[keyInCustomFields] = {
type: 'text', // we currently only support text custom fields
colour: maybeExistingColour,
label: ontimeLabel,
};
// 2. add the column to the import keys
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
}
return { mergedCustomFields, customFieldImportKeys };
}
@@ -0,0 +1,169 @@
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils';
/**
* Receives an import map which contains custom field labels and a custom fields object
* the result importkeys is an inverted record of <importKey, ontimeKey>
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
* @returns the new custom fields, and a map of excel column names to ontime keys
* @private exported for testing
*/
export function getCustomFieldData(
importMap: ImportMap,
existingCustomFields: CustomFields,
): {
mergedCustomFields: CustomFields;
customFieldImportKeys: Record<keyof CustomFields, string>;
} {
const mergedCustomFields: CustomFields = {};
/**
* A map of import keys to ontime keys
* Map<excel column name, ontime key>
*/
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
for (const ontimeLabel in importMap.custom) {
// if the label is not valid, we skip the import
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
continue;
}
// generate a key for the custom field
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
// we lower case the excel key to make it easier to match
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
// 1. add the custom field to the merged custom fields
mergedCustomFields[keyInCustomFields] = {
type: 'text', // we currently only support text custom fields
colour: maybeExistingColour,
label: ontimeLabel,
};
// 2. add the column to the import keys
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
}
return { mergedCustomFields, customFieldImportKeys };
}
/**
* Utility function infers a boolean from a string value
*/
export function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
// falsy values would be nullish or empty string
if (!value || typeof value !== 'string') {
return false;
}
return value.toLowerCase() !== 'false';
}
type IndexMap = Record<keyof Omit<ImportMap, 'worksheet' | 'custom'>, MaybeNumber> &
Record<keyof Pick<ImportMap, 'custom'>, Record<number, string>>;
export type SheetMetadata = Partial<
Record<keyof Omit<ImportMap, 'worksheet' | 'custom'>, { row: number; col: number }> &
Record<string, { row: number; col: number }>
>;
export function generateImportHandlers(importMap: ImportMap) {
const indexMap: IndexMap = {
title: null,
cue: null,
note: null,
colour: null,
flag: null,
skip: null,
countToEnd: null,
linkStart: null,
timeStart: null,
timeEnd: null,
duration: null,
timeWarning: null,
timeDanger: null,
endAction: null,
timerType: null,
entryId: null,
custom: {},
};
const sheetMetadata: SheetMetadata = {};
const handlers = {
[importMap.timeStart]: (row: number, col: number) => {
indexMap.timeStart = col;
sheetMetadata.timeStart = { row, col };
},
[importMap.linkStart]: (row: number, col: number) => {
indexMap.linkStart = col;
sheetMetadata.linkStart = { row, col };
},
[importMap.timeEnd]: (row: number, col: number) => {
indexMap.timeEnd = col;
sheetMetadata.timeEnd = { row, col };
},
[importMap.duration]: (row: number, col: number) => {
indexMap.duration = col;
sheetMetadata.duration = { row, col };
},
[importMap.cue]: (row: number, col: number) => {
indexMap.cue = col;
sheetMetadata.cue = { row, col };
},
[importMap.title]: (row: number, col: number) => {
indexMap.title = col;
sheetMetadata.title = { row, col };
},
[importMap.flag]: (row: number, col: number) => {
indexMap.flag = col;
sheetMetadata.flag = { row, col };
},
[importMap.countToEnd]: (row: number, col: number) => {
indexMap.countToEnd = col;
sheetMetadata.countToEnd = { row, col };
},
[importMap.skip]: (row: number, col: number) => {
indexMap.skip = col;
sheetMetadata.skip = { row, col };
},
[importMap.note]: (row: number, col: number) => {
indexMap.note = col;
sheetMetadata.note = { row, col };
},
[importMap.colour]: (row: number, col: number) => {
indexMap.colour = col;
sheetMetadata.colour = { row, col };
},
[importMap.endAction]: (row: number, col: number) => {
indexMap.endAction = col;
sheetMetadata.endAction = { row, col };
},
[importMap.timerType]: (row: number, col: number) => {
indexMap.timerType = col;
sheetMetadata.timerType = { row, col };
},
[importMap.timeWarning]: (row: number, col: number) => {
indexMap.timeWarning = col;
sheetMetadata.timeWarning = { row, col };
},
[importMap.timeDanger]: (row: number, col: number) => {
indexMap.timeDanger = col;
sheetMetadata.timeDanger = { row, col };
},
[importMap.entryId]: (row: number, col: number) => {
indexMap.entryId = col;
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
},
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
indexMap.custom[col] = columnText;
sheetMetadata[`custom:${ontimeKey}`] = { row, col };
},
};
return { handlers, indexMap, sheetMetadata };
}
+4 -4
View File
@@ -5,7 +5,7 @@ import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js'; import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js'; import { router as dbRouter } from './db/db.router.js';
import { router as projectRouter } from './project-data/projectData.router.js'; import { router as projectRouter } from './project-data/projectData.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js'; import { router as rundownsRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js'; import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js'; import { router as sheetsRouter } from './sheets/sheets.router.js';
import { router as excelRouter } from './excel/excel.router.js'; import { router as excelRouter } from './excel/excel.router.js';
@@ -20,7 +20,7 @@ appRouter.use('/automations', automationsRouter);
appRouter.use('/custom-fields', customFieldsRouter); appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/db', dbRouter); appRouter.use('/db', dbRouter);
appRouter.use('/project', projectRouter); appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter); appRouter.use('/rundowns', rundownsRouter);
appRouter.use('/settings', settingsRouter); appRouter.use('/settings', settingsRouter);
appRouter.use('/sheets', sheetsRouter); appRouter.use('/sheets', sheetsRouter);
appRouter.use('/excel', excelRouter); appRouter.use('/excel', excelRouter);
@@ -30,7 +30,7 @@ appRouter.use('/view-settings', viewSettingsRouter);
appRouter.use('/report', reportRouter); appRouter.use('/report', reportRouter);
appRouter.use('/assets', assetsRouter); appRouter.use('/assets', assetsRouter);
//we don't want to redirect to react index when using api routes // we don't want to redirect to react index when using api routes
appRouter.all('/*splat', (_req, res) => { appRouter.all('/*splat', (_req, res) => {
res.status(404).send('data path not found'); res.status(404).send('Unhandled request');
}); });
@@ -1,4 +1,4 @@
import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy, OntimeMilestone } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { import {
@@ -7,6 +7,7 @@ import {
makeOntimeGroup, makeOntimeGroup,
makeOntimeDelay, makeOntimeDelay,
makeCustomField, makeCustomField,
makeOntimeMilestone,
} from '../__mocks__/rundown.mocks.js'; } from '../__mocks__/rundown.mocks.js';
import { import {
@@ -17,7 +18,6 @@ import {
rundownMutation, rundownMutation,
} from '../rundown.dao.js'; } from '../rundown.dao.js';
import { demoDb } from '../../../models/demoProject.js'; import { demoDb } from '../../../models/demoProject.js';
import type { AssignedMap } from '../rundown.types.js';
import { type ProcessedRundownMetadata } from '../rundown.parser.js'; import { type ProcessedRundownMetadata } from '../rundown.parser.js';
const setRundownMock = vi.fn(); const setRundownMock = vi.fn();
@@ -554,10 +554,6 @@ describe('processRundown()', () => {
}); });
const initResult = processRundown(rundown, customProperties); const initResult = processRundown(rundown, customProperties);
expect(initResult.order.length).toBe(2); expect(initResult.order.length).toBe(2);
expect(initResult.assignedCustomFields).toMatchObject({
lighting: ['1', '2'],
sound: ['2'],
});
expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' }); expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({ expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({
lighting: 'event 2 lx', lighting: 'event 2 lx',
@@ -1742,22 +1738,26 @@ describe('customFieldMutation.renameUsages()', () => {
}, },
}); });
const assigned: AssignedMap = { customFieldMutation.renameUsages(rundown, 'one', 'new-one');
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one');
expect(rundown.entries).toMatchObject({ expect(rundown.entries).toMatchObject({
'1': { id: '1', custom: { 'new-one': 'value1' } }, '1': { id: '1', custom: { 'new-one': 'value1' } },
'2': { id: '2', custom: { 'new-one': 'value2' } }, '2': { id: '2', custom: { 'new-one': 'value2' } },
'3': { id: '3', custom: { two: 'value3' } }, '3': { id: '3', custom: { two: 'value3' } },
}); });
});
expect(assigned).toStrictEqual({ it('renames usages inside groups and milestones', () => {
'new-one': ['1', '2'], const rundown = makeRundown({
two: ['3'], order: ['group', 'm1'],
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['e1'] }),
e1: makeOntimeEvent({ id: 'e1', parent: 'group', custom: { one: 'v' } }),
m1: makeOntimeMilestone({ id: 'm1', custom: { two: 'keep' } }),
},
}); });
customFieldMutation.renameUsages(rundown, 'one', 'new-one');
expect((rundown.entries['e1'] as OntimeEvent).custom).toMatchObject({ 'new-one': 'v' });
expect((rundown.entries['m1'] as OntimeMilestone).custom).toMatchObject({ two: 'keep' });
}); });
}); });
@@ -1772,17 +1772,8 @@ describe('customFieldMutation.removeUsages()', () => {
}, },
}); });
const assigned: AssignedMap = { customFieldMutation.removeUsages(rundown, 'one');
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.removeUsages(rundown, assigned, 'one');
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one'); expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one'); expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
expect(assigned).toStrictEqual({
two: ['3'],
});
}); });
}); });
@@ -3,7 +3,7 @@ import { SupportedEntry, OntimeEvent, OntimeGroup, Rundown, CustomFields } from
import { defaultRundown } from '../../../models/dataModel.js'; import { defaultRundown } from '../../../models/dataModel.js';
import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js'; import { parseRundowns, parseRundown, sanitiseCustomFields } from '../rundown.parser.js';
describe('parseRundowns()', () => { describe('parseRundowns()', () => {
it('returns a default project rundown if nothing is given', () => { it('returns a default project rundown if nothing is given', () => {
@@ -293,20 +293,8 @@ describe('parseRundown()', () => {
}); });
}); });
describe('addToCustomAssignment()', () => { describe('sanitiseCustomFields()', () => {
it('adds given entry to assignedCustomFields', () => { it('deletes unused custom fields', () => {
const assignedCustomFields = {};
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
});
});
describe('handleCustomField()', () => {
it('creates a map of where custom fields are used', () => {
const customFields = { const customFields = {
lighting: { lighting: {
type: 'text', type: 'text',
@@ -327,13 +315,11 @@ describe('handleCustomField()', () => {
linkStart: true, linkStart: true,
custom: { custom: {
lighting: 'on', lighting: 'on',
unknown: 'does-not-exist',
}, },
}); });
const assignedCustomFields = {};
const result = handleCustomField(customFields, event, assignedCustomFields); sanitiseCustomFields(customFields, event);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
expect(event.custom).toStrictEqual({ expect(event.custom).toStrictEqual({
lighting: 'on', lighting: 'on',
}); });
+30 -58
View File
@@ -29,7 +29,7 @@ import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
applyPatchToEntry, applyPatchToEntry,
cloneGroup, cloneGroup,
@@ -67,15 +67,6 @@ let rundownMetadata: RundownMetadata = {
flags: [], flags: [],
}; };
const customFieldsMetadata: CustomFieldsMetadata = {
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
* since we can clear the custom fields from every event where they are used
*/
assigned: {},
};
/** /**
* The custom fields that are used in the project * The custom fields that are used in the project
* Not unique to the loaded rundown * Not unique to the loaded rundown
@@ -89,7 +80,6 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac
type Transaction = { type Transaction = {
customFields: CustomFields; customFields: CustomFields;
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
rundown: Rundown; rundown: Rundown;
rundownMetadata: Readonly<RundownMetadata>; rundownMetadata: Readonly<RundownMetadata>;
@@ -136,13 +126,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
const processedData = processRundown(rundown, projectCustomFields); const processedData = processRundown(rundown, projectCustomFields);
// update the cache values // update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } = const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
processedData;
cachedRundown.entries = entries; cachedRundown.entries = entries;
cachedRundown.order = order; cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; cachedRundown.flatOrder = metadata.flatEntryOrder;
customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata; rundownMetadata = metadata;
} }
} }
@@ -167,7 +155,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
return { return {
customFields, customFields,
customFieldsMetadata,
rundown, rundown,
rundownMetadata, rundownMetadata,
commit, commit,
@@ -564,6 +551,15 @@ export const rundownMutation = {
ungroup, ungroup,
}; };
/**
* Exposes a way to update a rundown which is not active
*/
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
setImmediate(async () => {
await getDataProvider().setRundown(rundownId, rundown);
});
}
/** /**
* Adds a new custom field to the object and returns it * Adds a new custom field to the object and returns it
*/ */
@@ -603,51 +599,29 @@ function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) {
} }
/** /**
* Renames a custom field key in all the rundown entries that use it * Iterates through all entries of a rundown and renames a custom field
*/ */
function customFieldRenameUsages( function customFieldRenameUsages(rundown: Rundown, oldKey: CustomFieldKey, newKey: CustomFieldKey) {
rundown: Rundown, Object.keys(rundown.entries).forEach((entryId) => {
assigned: AssignedMap, const entry = rundown.entries[entryId];
oldKey: CustomFieldKey, if ('custom' in entry && entry.custom[oldKey]) {
newKey: CustomFieldKey, // copy the data a new key and delete the old key
) { entry.custom[newKey] = entry.custom[oldKey];
const usages = assigned[oldKey]; delete entry.custom[oldKey];
}
// iterate through all the entries that use the custom field });
for (let i = 0; i < usages.length; i++) {
const entryId = usages[i];
const entry = rundown.entries[entryId] as OntimeEvent;
// copy the data a new key and delete the old key
entry.custom[newKey] = entry.custom[oldKey];
delete entry.custom[oldKey];
}
// update assignment
assigned[newKey] = [...assigned[oldKey]];
delete assigned[oldKey];
} }
/** /**
* Deletes data for a custom field from all the entries that use it * Iterates through all entries of a rundown and removes data associated with a custom field
*/ */
function customFieldRemoveUsages(rundown: Rundown, assigned: AssignedMap, key: CustomFieldKey) { function customFieldRemoveUsages(rundown: Rundown, key: CustomFieldKey) {
const usages = assigned[key]; Object.keys(rundown.entries).forEach((entryId) => {
if (!usages) { const entry = rundown.entries[entryId];
return; if ('custom' in entry && entry.custom[key]) {
} delete entry.custom[key];
}
// iterate through all the entries that use the custom field });
for (let i = 0; i < usages.length; i++) {
const entryId = usages[i];
const entry = rundown.entries[entryId] as OntimeEvent;
// delete the custom field entry
delete entry.custom[key];
}
// update assignment
delete assigned[key];
} }
export const customFieldMutation = { export const customFieldMutation = {
@@ -672,13 +646,11 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
projectCustomFields = customFields; projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } = const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
processedData;
cachedRundown.entries = entries; cachedRundown.entries = entries;
cachedRundown.order = order; cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; cachedRundown.flatOrder = metadata.flatEntryOrder;
cachedRundown.revision = rundown.revision; cachedRundown.revision = rundown.revision;
customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata; rundownMetadata = metadata;
// defer writing to the database // defer writing to the database
@@ -14,6 +14,8 @@ import {
RundownEntries, RundownEntries,
isPlayableEvent, isPlayableEvent,
isOntimeMilestone, isOntimeMilestone,
OntimeMilestone,
OntimeGroup,
} from 'ontime-types'; } from 'ontime-types';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
@@ -172,40 +174,16 @@ export function parseRundown(
} }
/** /**
* Utility function to add an entry, mutates given assignedCustomFields in place * Ensures that custom fields have references
* @param label * If a field is exists in the entry but not in the project customFields, it is deleted
* @param eventId * Mutates the given event in place
*/ */
export function addToCustomAssignment( export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
key: CustomFieldKey, for (const field in entry.custom) {
eventId: EntryId, if (field in customFields) continue;
assignedCustomFields: Record<string, string[]>, delete entry.custom[field];
) {
if (!Array.isArray(assignedCustomFields[key])) {
assignedCustomFields[key] = [];
}
assignedCustomFields[key].push(eventId);
}
/**
* Keeps track of which custom fields are assigned to which events
* Mutates the given assignedCustomFields in place
* If a field is referenced but is not in the customFields map, it is deleted
*/
export function handleCustomField(
customFields: CustomFields,
event: OntimeEvent,
assignedCustomFields: Record<CustomFieldKey, EntryId[]>,
) {
for (const field in event.custom) {
if (field in customFields) {
// add field to assignment map
addToCustomAssignment(field, event.id, assignedCustomFields);
} else {
// delete data if it is not declared in project level custom fields
delete event.custom[field];
}
} }
return entry;
} }
export type ProcessedRundownMetadata = RundownMetadata & { export type ProcessedRundownMetadata = RundownMetadata & {
@@ -292,7 +270,7 @@ function processEntry<T extends OntimeEntry>(
} }
// 2. handle custom fields - mutates currentEntry // 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields); sanitiseCustomFields(customFields, currentEntry);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent); processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays; currentEntry.dayOffset = processedData.totalDays;
+260 -102
View File
@@ -1,5 +1,5 @@
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; import { ErrorResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils'; import { generateId, getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import express from 'express'; import express from 'express';
@@ -14,40 +14,36 @@ import {
deleteEntries, deleteEntries,
editEntry, editEntry,
groupEntries, groupEntries,
initRundown,
reorderEntry, reorderEntry,
swapEvents, swapEvents,
ungroupEntries, ungroupEntries,
} from './rundown.service.js'; } from './rundown.service.js';
import { import {
rundownArrayOfIds, rundownArrayOfIds,
rundownBatchPutValidator, entryBatchPutValidator,
entryPostValidator,
rundownPostValidator, rundownPostValidator,
rundownPutValidator, entryPutValidator,
rundownReorderValidator, entryReorderValidator,
rundownSwapValidator, entrySwapValidator,
validateRundownMutation,
} from './rundown.validation.js'; } from './rundown.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js'; import { paramsWithId } from '../validation-utils/validationFunction.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { defaultRundown } from '../../models/dataModel.js';
import { normalisedToRundownArray } from './rundown.utils.js';
export const router = express.Router(); export const router = express.Router();
// #region operations on project rundowns =========================
/** /**
* Returns all rundowns in the project * Returns all rundowns in the project
*/ */
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => { router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
const rundown = getCurrentRundown(); const projectRundowns = getDataProvider().getProjectRundowns();
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
// TODO: we currently make a project with only the current rundown
res.json({
loaded: rundown.id,
rundowns: [
{
id: rundown.id,
title: rundown.title,
numEntries: rundown.order.length,
revision: rundown.revision,
},
],
});
}); });
/** /**
@@ -58,113 +54,275 @@ router.get('/current', async (_req: Request, res: Response<Rundown>) => {
res.json(rundown); res.json(rundown);
}); });
router.post('/', rundownPostValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => { /**
* Loads a given rundown
*/
router.post('/:id/load', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try { try {
const newEvent = await addEntry(req.body); // maybe the rundown is already loaded
res.status(201).send(newEvent); if (req.params.id === getCurrentRundown().id) {
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
return;
}
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
const customField = dataProvider.getCustomFields();
await initRundown(rundown, customField);
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(400).send({ message }); res.status(400).send({ message });
} }
}); });
router.put('/', rundownPutValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => { /**
* Creates a new rundown
*/
router.post('/', rundownPostValidator, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try { try {
const event = await editEntry(req.body); const id = generateId();
res.status(200).send(event); await getDataProvider().setRundown(id, { ...defaultRundown, id, title: req.body.title });
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(400).send({ message }); res.status(400).send({ message });
} }
}); });
router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => { /**
* Deletes a rundown if not loaded
*/
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try { try {
const rundown = await batchEditEntries(req.body.ids, req.body.data); if (req.params.id === getCurrentRundown().id) {
res.status(200).send(rundown); res.status(400).send({ message: 'Cannot delete loaded rundown' });
return;
}
const dataProvider = getDataProvider();
const projectRundowns = dataProvider.getProjectRundowns();
if (Object.keys(projectRundowns).length <= 1) {
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
res.status(400).send({ message: 'Cannot delete the last rundown' });
return;
}
await dataProvider.deleteRundown(req.params.id);
const newProjectRundowns = getDataProvider().getProjectRundowns();
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(400).send({ message }); res.status(400).send({ message });
} }
}); });
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => { // #endregion operations on project rundowns ======================
try {
const { entryId, destinationId, order } = req.body;
const newRundown = await reorderEntry(entryId, destinationId, order);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => { // #region operations on rundown entries ==========================
try {
const rundown = await swapEvents(req.body.from, req.body.to);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch('/applydelay/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => { /**
try { * Creates a new entry in a given rundown
const newRundown = await applyDelay(req.params.id); */
res.status(200).send(newRundown); router.post(
} catch (error) { '/:rundownId/entry',
const message = getErrorMessage(error); entryPostValidator,
res.status(400).send({ message }); validateRundownMutation,
} async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
}); try {
const newEvent = await addEntry(req.body);
res.status(201).send(newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.post('/clone/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => { /**
try { * Edits an entry in a given rundown
const newRundown = await cloneEntry(req.params.id); */
res.status(200).send(newRundown); router.put(
} catch (error) { '/:rundownId/entry',
const message = getErrorMessage(error); entryPutValidator,
res.status(400).send({ message }); validateRundownMutation,
} async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
}); try {
const event = await editEntry(req.body);
res.status(200).send(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.post('/group', rundownArrayOfIds, async (req: Request, res: Response<Rundown | ErrorResponse>) => { /**
try { * Edits an entry in a given rundown
const newRundown = await groupEntries(req.body.ids); */
res.status(200).send(newRundown); router.put(
} catch (error) { '/:rundownId/batch',
const message = getErrorMessage(error); entryBatchPutValidator,
res.status(400).send({ message }); validateRundownMutation,
} async (req: Request, res: Response<Rundown | ErrorResponse>) => {
}); try {
const rundown = await batchEditEntries(req.body.ids, req.body.data);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.post('/ungroup/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => { /**
try { * Reorders two entries in a rundown
const newRundown = await ungroupEntries(req.params.id); */
res.status(200).send(newRundown); router.patch(
} catch (error) { '/:rundownId/reorder',
const message = getErrorMessage(error); entryReorderValidator,
res.status(400).send({ message }); validateRundownMutation,
} async (req: Request, res: Response<Rundown | ErrorResponse>) => {
}); try {
const { entryId, destinationId, order } = req.body;
const rundown = await reorderEntry(entryId, destinationId, order);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => { /**
try { * Applies a delay into the schedule
await deleteEntries(req.body.ids); */
res.status(204).send({ message: 'Events deleted' }); router.patch(
} catch (error) { '/:rundownId/applydelay/:id',
const message = getErrorMessage(error); paramsWithId,
res.status(400).send({ message }); validateRundownMutation,
} async (req: Request, res: Response<Rundown | ErrorResponse>) => {
}); try {
const rundown = await applyDelay(req.params.id);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.delete('/all', async (_req: Request, res: Response<Rundown | ErrorResponse>) => { /**
try { * Swaps data between two Ontime events
const rundown = await deleteAllEntries(); */
res.status(204).send(rundown); router.patch(
} catch (error) { '/:rundownId/swap',
const message = getErrorMessage(error); entrySwapValidator,
res.status(400).send({ message }); validateRundownMutation,
} async (req: Request, res: Response<Rundown | ErrorResponse>) => {
}); try {
const rundown = await swapEvents(req.body.from, req.body.to);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Clones the contents of an entry into a new one
*/
router.post(
'/:rundownId/clone/:id',
paramsWithId,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await cloneEntry(req.params.id);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Creates a group out of a list of entries
*/
router.post(
'/:rundownId/group',
rundownArrayOfIds,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await groupEntries(req.body.ids);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Dissolves a group by moving its children to the main rundown
*/
router.post(
'/:rundownId/ungroup/:id',
paramsWithId,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await ungroupEntries(req.params.id);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Deletes a list of entries by their ID
*/
router.delete(
'/:rundownId/entries',
rundownArrayOfIds,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await deleteEntries(req.body.ids);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Deletes all entries in a given rundown
*/
router.delete(
'/:rundownId/all',
validateRundownMutation,
async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await deleteAllEntries();
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
// #endregion operations on rundown entries =======================
@@ -12,16 +12,26 @@ import {
PatchWithId, PatchWithId,
RefetchKey, RefetchKey,
Rundown, Rundown,
LogOrigin,
ProjectRundowns,
} from 'ontime-types'; } from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils'; import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js'; import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js'; import {
createTransaction,
customFieldMutation,
rundownCache,
rundownMutation,
updateBackgroundRundown,
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js'; import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
/** /**
* creates a new entry with given data * creates a new entry with given data
@@ -244,7 +254,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
* Handles moving across root orders (a group order and top level order) * Handles moving across root orders (a group order and top level order)
* @throws if entryId or destinationId not found * @throws if entryId or destinationId not found
*/ */
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') { export async function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false }); const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// check that both entries exist // check that both entries exist
@@ -383,8 +393,8 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata); updateRuntimeOnChange(rundownMetadata);
// we dont need to notify the timer since the grouping does not affect the runtime // we need to notify the timer since we might be grouping a running event
notifyChanges(rundownMetadata, revision, { external: true }); notifyChanges(rundownMetadata, revision, { external: true, timer: true });
}); });
return rundownResult; return rundownResult;
@@ -454,8 +464,12 @@ export async function createCustomField(customField: CustomField): Promise<Custo
* @throws if the label is missing or invalid * @throws if the label is missing or invalid
* @throws if the new label already exists * @throws if the new label already exists
*/ */
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFields> { export async function editCustomField(
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({ key: CustomFieldKey,
newField: Partial<CustomField>,
projectRundowns: ProjectRundowns,
): Promise<CustomFields> {
const { customFields, rundown, commit } = createTransaction({
mutableRundown: true, mutableRundown: true,
mutableCustomFields: true, mutableCustomFields: true,
}); });
@@ -472,14 +486,22 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField); const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
// if key has changed // if key has changed ...
if (oldKey !== newKey) { if (oldKey !== newKey) {
// 1. delete the old key // ... reassign references in the active rundown
customFieldMutation.remove(customFields, oldKey); customFieldMutation.renameUsages(rundown, oldKey, newKey);
if (oldKey in customFieldsMetadata.assigned) {
// 2. reassign references // ... reassign references in the background rundowns
customFieldMutation.renameUsages(rundown, customFieldsMetadata.assigned, oldKey, newKey); for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
updateBackgroundRundown(rundown.id, backgroundRundown);
}
} }
// ... delete the old key
customFieldMutation.remove(customFields, oldKey);
} }
// the custom fields have been removed and there is no processing to be done // the custom fields have been removed and there is no processing to be done
@@ -487,6 +509,7 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
// schedule the side effects // schedule the side effects
setImmediate(() => { setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
notifyChanges(rundownMetadata, revision, { timer: true, external: true }); notifyChanges(rundownMetadata, revision, { timer: true, external: true });
}); });
@@ -496,8 +519,8 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
/** /**
* Deletes an existing custom field * Deletes an existing custom field
*/ */
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> { export async function deleteCustomField(key: CustomFieldKey, projectRundowns: ProjectRundowns): Promise<CustomFields> {
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({ const { customFields, rundown, commit } = createTransaction({
mutableRundown: true, mutableRundown: true,
mutableCustomFields: true, mutableCustomFields: true,
}); });
@@ -505,16 +528,27 @@ export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFiel
return customFields; return customFields;
} }
customFieldMutation.remove(customFields, key); // remove references in the active rundown
if (key in customFieldsMetadata.assigned) { customFieldMutation.removeUsages(rundown, key);
customFieldMutation.removeUsages(rundown, customFieldsMetadata.assigned, key);
// remove references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key);
updateBackgroundRundown(rundown.id, backgroundRundown);
}
} }
// delete the old key
customFieldMutation.remove(customFields, key);
// the custom fields have been removed and there is no processing to be done // the custom fields have been removed and there is no processing to be done
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false); const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
// schedule the side effects // schedule the side effects
setImmediate(() => { setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
notifyChanges(rundownMetadata, revision, { timer: true, external: true }); notifyChanges(rundownMetadata, revision, { timer: true, external: true });
}); });
@@ -565,14 +599,20 @@ function notifyChanges(rundownMetadata: RundownMetadata, revision: number, optio
* Sets a new rundown in the cache * Sets a new rundown in the cache
* and marks it as the currently loaded one * and marks it as the currently loaded one
*/ */
export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) { export async function initRundown(
rundown: Readonly<Rundown>,
customFields: Readonly<CustomFields>,
reload: boolean = false,
) {
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields); const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata); updateRuntimeOnChange(rundownMetadata);
// notify timer of change
setImmediate(() => { setImmediate(() => {
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload: true }); notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
setLastLoadedRundown(rundown.id).catch((error) => {
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
});
}); });
} }
@@ -1,4 +1,4 @@
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types'; import { EntryId, MaybeNumber } from 'ontime-types';
export type RundownMetadata = { export type RundownMetadata = {
totalDelay: number; totalDelay: number;
@@ -12,8 +12,3 @@ export type RundownMetadata = {
flatEntryOrder: EntryId[]; // flat order of entries flatEntryOrder: EntryId[]; // flat order of entries
flags: EntryId[]; // flat order of flagged entries flags: EntryId[]; // flat order of flagged entries
}; };
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
export type CustomFieldsMetadata = {
assigned: AssignedMap;
};
@@ -15,6 +15,8 @@ import {
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeStrategy, TimeStrategy,
ProjectRundown,
ProjectRundowns,
} from 'ontime-types'; } from 'ontime-types';
import { import {
dayInMs, dayInMs,
@@ -506,3 +508,12 @@ export function getTimedIndexFromPlayableIndex(metadata: RundownMetadata, index:
const timedIndex = metadata.timedEventOrder.findIndex((id) => id === playableId); const timedIndex = metadata.timedEventOrder.findIndex((id) => id === playableId);
return timedIndex; return timedIndex;
} }
/**
* converts a project rundowns map into an array of rundowns
*/
export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRundown[] {
return Object.values(rundowns).map(({ id, flatOrder, title, revision }) => {
return { id, numEntries: flatOrder.length, title, revision };
});
}
@@ -1,7 +1,40 @@
import type { Request, Response, NextFunction } from 'express';
import { body, param } from 'express-validator'; import { body, param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const rundownPostValidator = [ import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { getCurrentRundown } from './rundown.dao.js';
// #region operations on project rundowns =========================
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
/**
* Middleware prevents mutating a rundown that is not selected
* This allows our service to still only handle the current rundown
*
* This would need to be removed in favour or rundown selection if we would like
* to implement the mutation of background rundowns
*/
export async function validateRundownMutation(req: Request, res: Response, next: NextFunction) {
const { rundownId } = req.params;
try {
if (getCurrentRundown().id !== rundownId) {
res.status(404).json({ message: 'Cannot mutate not selected rundown' });
return;
}
next();
} catch (error) {
res.status(404).json({ message: 'Rundown not found' });
return;
}
}
export const entryPostValidator = [
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']), body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
body('after').optional().isString(), body('after').optional().isString(),
body('before').optional().isString(), body('before').optional().isString(),
@@ -9,9 +42,9 @@ export const rundownPostValidator = [
requestValidationFunction, requestValidationFunction,
]; ];
export const rundownPutValidator = [body('id').isString().notEmpty(), requestValidationFunction]; export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction];
export const rundownBatchPutValidator = [ export const entryBatchPutValidator = [
body('data').isObject(), body('data').isObject(),
body('ids').isArray().notEmpty(), body('ids').isArray().notEmpty(),
body('ids.*').isString(), body('ids.*').isString(),
@@ -19,7 +52,7 @@ export const rundownBatchPutValidator = [
requestValidationFunction, requestValidationFunction,
]; ];
export const rundownReorderValidator = [ export const entryReorderValidator = [
body('entryId').isString().notEmpty(), body('entryId').isString().notEmpty(),
body('destinationId').isString().notEmpty(), body('destinationId').isString().notEmpty(),
body('order').isIn(['before', 'after', 'insert']), body('order').isIn(['before', 'after', 'insert']),
@@ -27,7 +60,7 @@ export const rundownReorderValidator = [
requestValidationFunction, requestValidationFunction,
]; ];
export const rundownSwapValidator = [ export const entrySwapValidator = [
body('from').isString().notEmpty(), body('from').isString().notEmpty(),
body('to').isString().notEmpty(), body('to').isString().notEmpty(),
@@ -42,3 +75,5 @@ export const rundownArrayOfIds = [
requestValidationFunction, requestValidationFunction,
]; ];
// #endregion operations on rundown entries =======================
@@ -5,7 +5,7 @@ import { publicDir } from '../../setup/index.js';
import { socket } from '../../adapters/WebsocketAdapter.js'; import { socket } from '../../adapters/WebsocketAdapter.js';
import { getLastRequest } from '../../api-integration/integration.controller.js'; import { getLastRequest } from '../../api-integration/integration.controller.js';
import { getCurrentProject } from '../../services/project-service/ProjectService.js'; import { getCurrentProject } from '../../services/project-service/ProjectService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { getNetworkInterfaces } from '../../utils/network.js'; import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js'; import { getTimezoneLabel } from '../../utils/time.js';
import { password, routerPrefix } from '../../externals.js'; import { password, routerPrefix } from '../../externals.js';

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