Compare commits

...

9 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
Carlos Valente 3c41b40c5f refactor: create fast tracks for cookie parsing 2025-08-18 22:00:16 +02:00
arc-alex 3b38756dda fix: use new token with scope 2025-08-18 22:00:16 +02:00
Carlos Valente 5a4494ba85 refactor: improve timers visibility 2025-08-18 22:00:16 +02:00
Carlos Valente 02ad852dd4 fix: external demo connects to hash 2025-08-18 22:00:16 +02:00
Carlos Valente 7ce1097826 fix: prevent reflow between entry editors 2025-08-18 22:00:16 +02:00
Carlos Valente 7ee0de5341 docs: improve development documentation 2025-08-18 22:00:16 +02:00
Carlos Valente 0db249871b bump version to 4.0.0-alpha.4 2025-08-18 22:00:16 +02:00
133 changed files with 3493 additions and 2258 deletions
+13 -19
View File
@@ -20,19 +20,20 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
- __Run dev mode__ by running `pnpm turbo dev`
### Debugging backend
To debug backend code in Node.js:
The previous command will start the development servers for both the client, server and electron applications.
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
## TESTING
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `turbo run test:pipeline` from the project root.
You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo run build:electron`
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
- __Build the UI and server__ by running `pnpm turbo run build:electron`
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
@@ -98,10 +99,3 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## General Info
# APP Building
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.3",
"version": "4.0.0-alpha.5",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.3",
"version": "4.0.0-alpha.5",
"private": true,
"type": "module",
"dependencies": {
@@ -29,6 +29,7 @@
"react-qr-code": "^2.0.18",
"react-router": "^7.8.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.7"
},
+22 -1
View File
@@ -1,6 +1,9 @@
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`;
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
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 CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
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 {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
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
@@ -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> {
const res = await axios.get(`${rundownPath}/current`);
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
*/
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data);
export async function postAddEntry(
rundownId: string,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
}
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data);
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
type BatchEditEntry = {
export type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
ids: EntryId[];
};
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data);
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
export type ReorderEntry = {
@@ -64,60 +85,57 @@ export type ReorderEntry = {
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data);
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/swap`, data);
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a group
*/
export async function requestUngroup(groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${groupId}`);
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
}
/**
* HTTP request for grouping a list of entries into a group
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
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>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
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>> {
return axios.delete(`${rundownPath}/all`);
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
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,12 +1,13 @@
.list {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 0.5rem;
}
.swatch {
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
color: $ui-white;
@@ -14,7 +14,8 @@
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
padding-top: 0.75rem;
padding-left: 0.5em;
position: fixed;
left: 0;
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker';
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
return value;
};
export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
const [colour, setColour] = useState(() => ensureHex(value));
useEffect(() => {
setColour(ensureHex(value));
}, [value]);
return (
<div className={style.inline}>
<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 { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select from '../select/Select';
import Select, { SelectOption } from '../select/Select';
import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker';
@@ -35,7 +35,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
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') {
@@ -43,7 +43,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
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') {
@@ -63,15 +63,13 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'colour') {
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
return <InlineColourPicker name={id} value={currentvalue} />;
return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
const { placeholder } = paramField;
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
}
interface EditFormMultiOptionProps {
@@ -83,7 +81,12 @@ function MultiOption({ paramField }: EditFormMultiOptionProps) {
const { id, values, defaultValue = [''] } = paramField;
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) => {
if (checked) {
@@ -129,5 +132,52 @@ interface ControlledSwitchProps {
}
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
const [checked, setChecked] = useState(initialValue);
// synchronise checked state
useEffect(() => {
setChecked(initialValue);
}, [initialValue]);
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 { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
@@ -12,7 +12,7 @@ import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsShare } from './ViewParamShare';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
@@ -24,12 +24,9 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
// TODO: can we ensure that the options update when the user loads an alias?
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
// TODO: we dont want this as a permanent option
const forceRender = useReducer((x) => x + 1, 0)[1];
const handleClose = () => {
close();
@@ -37,7 +34,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const resetParams = () => {
setSearchParams();
forceRender();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -46,7 +42,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
forceRender();
};
return (
@@ -71,7 +66,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
{viewSettings.overrideStyles && (
<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}>
{viewOptions.map((section) => (
<ViewParamsSection
@@ -5,6 +5,9 @@
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
}
.preset {
@@ -5,17 +5,19 @@ import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
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
*/
export function ViewParamsShare({ target }: { target: OntimeView }) {
export function ViewParamsPresets({ target }: { target: OntimeView }) {
const { viewPresets } = useViewUrlPresets(target);
const [_, setSearchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
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) {
@@ -25,12 +27,12 @@ export function ViewParamsShare({ target }: { target: OntimeView }) {
return (
<div className={style.presetSection}>
{viewPresets.map((preset) => {
const active = window.location.search.includes(`alias=${preset.alias}`);
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant='subtle-white'
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
@@ -44,3 +44,7 @@
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
}
.hidden {
display: none;
}
@@ -47,15 +47,11 @@ interface SectionContentsProps {
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
if (collapsed) {
return null;
}
return (
<>
{options.map((option) => {
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.description}>{option.description}</span>
<ParamInput paramField={option} />
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
type MultiOptionsField = {
type: 'multi-option';
values: MultiselectOption[];
defaultValue?: string;
defaultValue?: 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 { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { fetchProjectRundownList } from '../api/rundown';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
/**
* Project rundowns
@@ -13,10 +13,43 @@ export function useProjectRundowns() {
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
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 { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
@@ -26,14 +28,18 @@ export default function useRundown() {
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
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
* built from the order and rundown fields
@@ -68,6 +74,14 @@ export function useFlatRundown() {
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
*/
+166 -94
View File
@@ -30,7 +30,6 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
@@ -59,15 +58,25 @@ export const useEntryActions = () => {
defaultEndAction,
} = 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(
(eventId: string): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
(eventId: EntryId): OntimeEntry | undefined => {
const cachedRundown = getCurrentRundownData();
if (!cachedRundown?.entries) {
return;
}
return cachedRundown.entries[eventId];
},
[queryClient],
[getCurrentRundownData],
);
/**
@@ -75,8 +84,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -85,13 +94,19 @@ export const useEntryActions = () => {
*/
const addEntry = useCallback(
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() };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
if (options?.lastEventId) {
// 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];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -135,21 +150,21 @@ export const useEntryActions = () => {
}
try {
await addEntryMutation(newEntry);
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[
addEntryMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
getCurrentRundownData,
linkPrevious,
queryClient,
defaultDuration,
defaultDangerTime,
defaultWarnTime,
defaultTimerType,
defaultEndAction,
defaultTimeStrategy,
addEntryMutation,
],
);
@@ -158,22 +173,28 @@ export const useEntryActions = () => {
* @private
*/
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 }),
});
/**
* Clone a selection
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
await cloneEntryMutation(entryId);
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[cloneEntryMutation],
[cloneEntryMutation, getCurrentRundownData],
);
/**
@@ -181,9 +202,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: updateEntryMutation } = useMutation({
mutationFn: putEditEntry,
mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
// we optimistically update here
onMutate: async (newEvent) => {
onMutate: async ([_rundownId, newEvent]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -211,7 +232,9 @@ export const useEntryActions = () => {
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -226,19 +249,17 @@ export const useEntryActions = () => {
const updateEntry = useCallback(
async (entry: Partial<OntimeEntry>) => {
try {
await updateEntryMutation(entry);
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await updateEntryMutation([rundownId, entry]);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[updateEntryMutation],
);
const updateCustomField = useCallback(
async (entryId: EntryId, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } });
},
[updateEntry],
[getCurrentRundownData, updateEntryMutation],
);
/**
@@ -250,6 +271,11 @@ export const useEntryActions = () => {
*/
const updateTimer = useCallback(
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
if (!lockOnUpdate && value === '') {
return;
@@ -279,7 +305,7 @@ export const useEntryActions = () => {
}
try {
await updateEntryMutation(newEvent);
await updateEntryMutation([rundownId, newEvent]);
} catch (error) {
logAxiosError('Error updating event', error);
}
@@ -331,7 +357,7 @@ export const useEntryActions = () => {
return previousEnd;
}
},
[updateEntryMutation, queryClient],
[getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
@@ -339,8 +365,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => {
mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
onMutate: async ([_rundownId, data]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -348,7 +374,7 @@ export const useEntryActions = () => {
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousRundown) {
const eventIds = new Set(ids);
const eventIds = new Set(data.ids);
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
@@ -395,14 +421,19 @@ export const useEntryActions = () => {
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
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) {
logAxiosError('Error updating events', error);
}
},
[batchUpdateEventsMutation],
[batchUpdateEventsMutation, getCurrentRundownData],
);
/**
@@ -410,9 +441,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: deleteEntryMutation } = useMutation({
mutationFn: deleteEntries,
mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
// we optimistically update here
onMutate: async (entryIds: EntryId[]) => {
onMutate: async ([_rundownId, entryIds]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -454,12 +485,17 @@ export const useEntryActions = () => {
const deleteEntry = useCallback(
async (entryIds: EntryId[]) => {
try {
await deleteEntryMutation(entryIds);
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteEntryMutation([rundownId, entryIds]);
} catch (error) {
logAxiosError('Error deleting event', error);
}
},
[deleteEntryMutation],
[deleteEntryMutation, getCurrentRundownData],
);
/**
@@ -467,7 +503,7 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
mutationFn: requestDeleteAll,
mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
@@ -506,18 +542,24 @@ export const useEntryActions = () => {
*/
const deleteAllEntries = useCallback(async () => {
try {
await deleteAllEntriesMutation();
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteAllEntriesMutation([rundownId]);
} catch (error) {
logAxiosError('Error deleting events', error);
}
}, [deleteAllEntriesMutation]);
}, [deleteAllEntriesMutation, getCurrentRundownData]);
/**
* Calls mutation to apply a delay
* @private
*/
const { mutateAsync: applyDelayMutation } = useMutation({
mutationFn: requestApplyDelay,
mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => {
if (!response.data) return;
@@ -543,12 +585,17 @@ export const useEntryActions = () => {
const applyDelay = useCallback(
async (delayEventId: EntryId) => {
try {
await applyDelayMutation(delayEventId);
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await applyDelayMutation([rundownId, delayEventId]);
} catch (error) {
logAxiosError('Error applying delay', error);
}
},
[applyDelayMutation],
[applyDelayMutation, getCurrentRundownData],
);
/**
@@ -556,7 +603,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: ungroupMutation } = useMutation({
mutationFn: requestUngroup,
mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => {
if (!response.data) return;
@@ -579,12 +627,17 @@ export const useEntryActions = () => {
const ungroup = useCallback(
async (groupId: EntryId) => {
try {
await ungroupMutation(groupId);
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await ungroupMutation([rundownId, groupId]);
} catch (error) {
logAxiosError('Error dissolving group', error);
}
},
[ungroupMutation],
[getCurrentRundownData, ungroupMutation],
);
/**
@@ -592,7 +645,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: groupEntriesMutation } = useMutation({
mutationFn: requestGroupEntries,
mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
requestGroupEntries(rundownId, entryIds),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => {
if (!response.data) return;
@@ -617,19 +672,24 @@ export const useEntryActions = () => {
if (entryIds.length === 0) return;
try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
if (entryIds.length === 1) {
await groupEntriesMutation(entryIds);
await groupEntriesMutation([rundownId, entryIds]);
} else {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) return;
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
await groupEntriesMutation(orderedIds);
// the user selection may be out of order
const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
await groupEntriesMutation([rundownId, orderedIds]);
}
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[groupEntriesMutation, queryClient],
[getCurrentRundownData, groupEntriesMutation],
);
/**
@@ -637,9 +697,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: reorderEntryMutation } = useMutation({
mutationFn: patchReorderEntry,
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
@@ -650,34 +709,36 @@ export const useEntryActions = () => {
*/
const move = useCallback(
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 {
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 = {
entryId,
destinationId,
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) {
logAxiosError('Error re-ordering event', error);
}
// the rundown needs to know whether we moved into a group
return rundown.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
return undefined;
},
[queryClient, reorderEntryMutation],
[getCurrentRundownData, reorderEntryMutation],
);
/**
* Reorders a given entry
@@ -685,18 +746,25 @@ export const useEntryActions = () => {
const reorderEntry = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
};
await reorderEntryMutation(reorderObject);
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await reorderEntryMutation([
rundownId,
{
entryId,
destinationId,
order,
},
]);
} catch (error) {
logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component
}
},
[reorderEntryMutation],
[getCurrentRundownData, reorderEntryMutation],
);
/**
@@ -704,9 +772,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: swapEventsMutation } = useMutation({
mutationFn: requestEventSwap,
mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
// we optimistically update here
onMutate: async ({ from, to }) => {
onMutate: async ([_rundownId, from, to]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -755,14 +823,19 @@ export const useEntryActions = () => {
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
async (from: EntryId, to: EntryId) => {
try {
await swapEventsMutation({ from, to });
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await swapEventsMutation([rundownId, from, to]);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[swapEventsMutation],
[getCurrentRundownData, swapEventsMutation],
);
return {
@@ -780,7 +853,6 @@ export const useEntryActions = () => {
swapEvents,
updateEntry,
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,
RUNDOWN,
RUNTIME,
TRANSLATION,
URL_PRESETS,
VIEW_SETTINGS,
} from '../api/constants';
@@ -173,6 +174,9 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break;
case RefetchKey.Translation:
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
break;
default: {
target satisfies never;
break;
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}>
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -106,7 +106,6 @@ $inner-padding: 1rem;
th,
td {
padding: 0.5rem;
vertical-align: top;
}
tr:nth-child(even) {
@@ -6,14 +6,6 @@
width: 100%;
}
.fieldForm {
padding: 1rem;
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 1rem;
}
.twoCols {
display: grid;
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 { useDisclosure } from '@mantine/hooks';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
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 * as Panel from '../../panel-utils/PanelUtils';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const [deleteOpen, deleteHandlers] = useDisclosure();
const [loadOpen, loadHandlers] = useDisclosure();
const { remove, load } = useMutateProjectRundowns();
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 (
<>
@@ -21,49 +62,60 @@ export default function ManageRundowns() {
<Panel.SubHeader>
Manage project rundowns
<Panel.InlineElements>
<Button onClick={() => undefined} disabled>
<Button
onClick={() => {
setActionError(null);
newHandlers.open();
}}
>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Table>
<thead>
<tr>
<th># Entries</th>
<th style={{ width: '100%' }}>Title</th>
<th />
</tr>
</thead>
<tbody>
{data.rundowns.map((rundown) => {
const isLoaded = data.loaded === rundown.id;
return (
<tr key={rundown.id} className={cx([isLoaded && style.current])}>
<td>{rundown.numEntries}</td>
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => deleteHandlers.open()}
disabled={isLoaded}
>
Delete
</Button>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
<Panel.Section>
{isNewLoad && <ManageRundownForm onClose={newHandlers.close} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table>
<thead>
<tr>
<th># Entries</th>
<th style={{ width: '100%' }}>Title</th>
<th />
</tr>
</thead>
<tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id;
return (
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
<td>
{title} {isLoaded && <Tag>Loaded</Tag>}
</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => openDelete(id)}
disabled={isLoaded}
>
Delete
</Button>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
<Dialog
isOpen={deleteOpen}
isOpen={isOpenDelete}
onClose={deleteHandlers.close}
title='Load rundown'
showBackdrop
@@ -78,16 +130,16 @@ export default function ManageRundowns() {
<Button size='large' onClick={deleteHandlers.close}>
Cancel
</Button>
<Button variant='destructive' size='large' onClick={() => undefined}>
<Button variant='destructive' size='large' onClick={submitRundownDelete}>
Delete rundown
</Button>
</>
}
/>
<Dialog
isOpen={loadOpen}
isOpen={isOpenLoad}
onClose={loadHandlers.close}
title='Delete rundown'
title='Load rundown'
showBackdrop
showCloseButton
bodyElements={
@@ -100,7 +152,7 @@ export default function ManageRundowns() {
<Button size='large' onClick={loadHandlers.close}>
Cancel
</Button>
<Button variant='primary' size='large' onClick={() => undefined}>
<Button variant='primary' size='large' onClick={submitRundownLoad}>
Load rundown
</Button>
</>
@@ -83,11 +83,7 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined;
return (
<form
onSubmit={handleSubmit(setupSubmit)}
className={style.fieldForm}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
@@ -107,7 +103,7 @@ export default function CustomFieldForm({
/>
</div>
<div className={style.twoCols}>
<div>
<label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
@@ -116,7 +112,8 @@ export default function CustomFieldForm({
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
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 && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
@@ -125,17 +122,17 @@ export default function CustomFieldForm({
})}
fluid
/>
</div>
</label>
<div>
<label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} readOnly fluid />
</div>
<Input {...register('key')} variant='ghosted' readOnly fluid />
</label>
</div>
<div>
<label>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div>
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
@@ -145,6 +142,6 @@ export default function CustomFieldForm({
Save
</Button>
</Panel.InlineElements>
</form>
</Panel.Indent>
);
}
@@ -12,6 +12,7 @@ tr .secondaryRow {
}
.linkStartActive {
flex-shrink: 0;
color: $active-indicator;
transform: rotate(-45deg);
}
@@ -1,6 +1,6 @@
import { Fragment } from 'react';
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 Tag from '../../../../../../common/components/tag/Tag';
@@ -53,9 +53,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr>
</thead>
<tbody>
{rundown.order.map((entryId) => {
{rundown.flatOrder.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeGroup(entry)) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return (
<tr key={entry.id}>
<td className={style.center}>
@@ -64,11 +65,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td className={style.center}>
<Tag>{entry.type}</Tag>
</td>
<td />
<td colSpan={99}>{entry.title}</td>
<td /> {/** CUE */}
<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>
);
}
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)) {
return null;
}
@@ -107,14 +172,13 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td className={style.center}>
<Tag>{entry.endAction}</Tag>
</td>
{isOntimeEvent(entry) &&
fieldKeys.map((field) => {
let value = '';
if (field in entry.custom) {
value = entry.custom[field];
}
return <td key={field}>{value}</td>;
})}
{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>
@@ -58,7 +58,7 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
};
return (
<Panel.Section
<Panel.Indent
as='form'
onSubmit={handleSubmit(handleSubmitCreate)}
onKeyDown={(event) => preventEscape(event, onClose)}
@@ -76,11 +76,9 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}>
<label>
Project title
<Input fluid placeholder='Your project name' {...register('title')} />
</label>
<Panel.Description>Project title</Panel.Description>
<Input fluid placeholder='Your project name' {...register('title')} />
</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 { useDisclosure } from '@mantine/hooks';
import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings';
@@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput';
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
export default function GeneralSettings() {
const { data, status, refetch } = useSettings();
const {
@@ -34,6 +37,8 @@ export default function GeneralSettings() {
},
});
const [isOpen, handler] = useDisclosure();
// update form if we get new data from server
useEffect(() => {
if (data) {
@@ -63,112 +68,124 @@ export default function GeneralSettings() {
const isLoading = status === 'pending';
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='app-settings'
>
<Panel.Card>
<Panel.SubHeader>
General settings
<Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
disabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Editor pin code'
description='Protect the editor view with a pin code'
error={errors.editorKey?.message}
/>
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Operator pin code'
description='Protect the operator and cuesheet views with a pin code'
error={errors.operatorKey?.message}
/>
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Time format'
description='Default time format to show in views 12 /24 hours'
error={errors.timeFormat?.message}
/>
<Select
value={watch('timeFormat')}
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
defaultValue='24'
options={[
{ value: '12', label: '12 hours 11:00:10 PM' },
{ value: '24', label: '24 hours 23:00:10' },
]}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Views language'
description='Language to be displayed in views'
error={errors.language?.message}
/>
<Select
value={watch('language')}
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
disabled={disableInputs}
defaultValue='en'
options={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'es', label: 'Spanish' },
]}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
<>
<TranslationModal isOpen={isOpen} onClose={handler.close} />
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='app-settings'
>
<Panel.Card>
<Panel.SubHeader>
General settings
<Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='app-settings'
name='general-settings-submit'
loading={isSubmitting}
disabled={disableSubmit}
variant='primary'
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
disabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Editor pin code'
description='Protect the editor view with a pin code'
error={errors.editorKey?.message}
/>
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Operator pin code'
description='Protect the operator and cuesheet views with a pin code'
error={errors.operatorKey?.message}
/>
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Time format'
description='Default time format to show in views 12 /24 hours'
error={errors.timeFormat?.message}
/>
<Select
value={watch('timeFormat')}
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
defaultValue='24'
options={[
{ value: '12', label: '12 hours 11:00:10 PM' },
{ value: '24', label: '24 hours 23:00:10' },
]}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Views language'
description='Language to be displayed in views'
error={errors.language?.message}
/>
<Select
value={watch('language')}
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
disabled={disableInputs}
defaultValue='en'
options={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ 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('_', '.');
}
@@ -7,8 +7,8 @@
}
.timers {
padding-block: 0.75rem 0.5rem;
padding-inline: 1rem 0;
padding-block: 1rem 0.5rem;
padding-inline: 1rem 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -18,11 +18,21 @@
.runningTimer {
grid-area: timers;
justify-self: center;
:nth-child(2) {
font-size: 2.5rem;
line-height: 1.2;
}
}
.timeNow {
grid-area: clock;
justify-self: right;
:nth-child(2) {
font-size: 2.5rem;
line-height: 1.2;
}
}
.progressOverride {
@@ -144,11 +144,15 @@ function FlagTimes() {
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>{plannedTimeUntilDisplay}</span>
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
{plannedTimeUntilDisplay}
</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>{expectedTimeUntilDisplay}</span>
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
{expectedTimeUntilDisplay}
</span>
</div>
</div>
);
+62 -49
View File
@@ -37,13 +37,14 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils';
import { canDrop, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -53,13 +54,15 @@ const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
data: Rundown;
rundownMetadata: RundownMetadataObject;
}
export default function Rundown({ data }: RundownProps) {
export default function Rundown({ data, rundownMetadata }: RundownProps) {
const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState(rundownMetadata);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
@@ -306,7 +309,8 @@ export default function Rundown({ data }: RundownProps) {
// to workaround async updates on the drag mutations
useEffect(() => {
setSortableData(makeSortableList(order, entries));
}, [order, entries]);
setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]);
// in run mode, we follow selection
useEffect(() => {
@@ -334,19 +338,24 @@ export default function Rundown({ data }: RundownProps) {
return;
}
// prevent dropping a group inside another
if (
active.data.current?.type === SupportedEntry.Group &&
!canDrop(over.data.current?.type, over.data.current?.parent)
) {
if (!active.data.current || !over.data.current) {
return;
}
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
const fromIndex: number = active.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 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
@@ -357,16 +366,25 @@ export default function Rundown({ data }: RundownProps) {
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
if (order === 'before') {
order = 'insert';
if (placement === 'before') {
placement = 'insert';
}
} else {
const group = data.entries[destinationId];
if (isOntimeGroup(group) && order === 'after') {
if (group.entries.length === 0) order = 'insert';
else {
// if dragging into a group
if (isOntimeGroup(group) && placement === 'after') {
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];
order = 'before';
placement = 'before';
}
}
}
@@ -377,7 +395,7 @@ export default function Rundown({ data }: RundownProps) {
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEntry(active.id as EntryId, destinationId, order).catch((_) => {
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries);
});
};
@@ -418,11 +436,6 @@ export default function Rundown({ data }: RundownProps) {
// 1. gather presentation options
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 (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext
@@ -440,6 +453,7 @@ export default function Rundown({ data }: RundownProps) {
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
const isGroupCollapsed = getIsCollapsed(parentId);
const parentMetadata = metadata[parentId];
if (isGroupCollapsed) {
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
return (
<Fragment key={entryId}>
{isEditMode && rundownMetadata.groupEntries === 0 && (
{isEditMode && parentMetadata?.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
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>
);
}
@@ -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
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) return null;
rundownMetadata = process(entry);
const entryMetadata = metadata[entryId];
if (!entry || !entryMetadata) return null;
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Group &&
rundownMetadata.groupId !== null &&
getIsCollapsed(rundownMetadata.groupId)
entryMetadata.groupId !== null &&
getIsCollapsed(entryMetadata.groupId)
) {
return null;
}
@@ -488,7 +501,7 @@ export default function Rundown({ data }: RundownProps) {
* ie: we are inside a group, but there is no defined colour
* 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 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
*/
const parentIdForBefore =
rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null;
const parentIdForAfter = rundownMetadata.groupId;
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
const parentIdForAfter = entryMetadata.groupId;
return (
<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)
*/}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentGroup={parentIdForBefore} />
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
)}
{isOntimeGroup(entry) ? (
<RundownGroup
@@ -525,31 +537,29 @@ export default function Rundown({ data }: RundownProps) {
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${rundownMetadata.eventIndex}`}
data-testid={`entry-${entryMetadata.eventIndex}`}
style={groupColour ? { '--user-bg': groupColour } : {}}
>
{isOntimeEvent(entry) && (
<div className={style.entryIndex}>
{entry.flag && <TbFlagFilled className={style.flag} />}
<div className={style.index}>{rundownMetadata.eventIndex}</div>
<div className={style.index}>{entryMetadata.eventIndex}</div>
</div>
)}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={rundownMetadata.isPast}
eventIndex={rundownMetadata.eventIndex}
isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex}
data={entry}
loaded={rundownMetadata.isLoaded}
loaded={entryMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousEntryId={rundownMetadata.previousEntryId}
previousEventId={rundownMetadata.previousEvent?.id}
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isNextDay={entryMetadata.isNextDay}
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={rundownMetadata.isNextDay}
totalGap={rundownMetadata.totalGap}
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
totalGap={entryMetadata.totalGap}
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
/>
</div>
</div>
@@ -562,13 +572,16 @@ export default function Rundown({ data }: RundownProps) {
* - if the entry is not the group header
*/}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline previousEventId={entry.id} parentGroup={parentIdForAfter} />
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
)}
</Fragment>
);
})}
{isEditMode && (
<QuickAddButtons previousEventId={rundownMetadata.groupId ?? rundownMetadata.thisId} parentGroup={null} />
<QuickAddButtons
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
parentGroup={null}
/>
)}
<div className={style.spacer} />
</div>
@@ -1,9 +1,7 @@
import { useCallback } from 'react';
import {
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
MaybeString,
OntimeEntry,
OntimeEvent,
Playback,
@@ -12,26 +10,11 @@ import {
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/clone';
import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent';
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 {
type: SupportedEntry;
@@ -42,8 +25,6 @@ interface RundownEntryProps {
hasCursor: boolean;
isNext: boolean;
isNextDay: boolean;
previousEntryId: MaybeString;
previousEventId?: string;
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
totalGap: number;
@@ -56,8 +37,6 @@ export default function RundownEntry({
loaded,
hasCursor,
isNext,
previousEntryId,
previousEventId,
playback,
isRolling,
eventIndex,
@@ -65,105 +44,11 @@ export default function RundownEntry({
totalGap,
isLinkedToLoaded,
}: RundownEntryProps) {
const { emitError } = useEmitLog();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const { addEntry } = useEntryActions();
const removeOpenEvent = useCallback(() => {
unselect(data.id);
}, [unselect, 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}`);
}
}
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
if (isOntimeEvent(data)) {
@@ -198,7 +83,7 @@ export default function RundownEntry({
dayOffset={data.dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
actionHandler={actionHandler}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0}
/>
);
@@ -43,6 +43,5 @@
border-radius: 0 8px 8px 0;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
min-width: 30rem;
max-width: 45rem;
}
@@ -1,5 +1,5 @@
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 RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
@@ -12,12 +12,16 @@ interface RundownWrapperProps {
}
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
const { data, status } = useRundown();
const { data, status, rundownMetadata } = useRundownWithMetadata();
return (
<div className={styles.rundownWrapper}>
{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>
);
}
@@ -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';
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,
});
});
});
import { makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils';
describe('makeSortableList()', () => {
it('generates a list with group ends', () => {
@@ -327,7 +40,7 @@ describe('makeSortableList()', () => {
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 entries: RundownEntries = {
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup,
@@ -1,10 +1,11 @@
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 EventEditor from './EventEditor';
import GroupEditor from './GroupEditor';
import MilestoneEditor from './MilestoneEditor';
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)) {
return (
<div className={style.inModal} data-testid='editor-container'>
@@ -1,10 +1,21 @@
.entryEditor {
.cuesheetEditor,
.rundownEditor {
max-height: 100%;
display: flex;
flex-direction: column;
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 {
padding-inline: 0.5rem 1.5rem;
padding-bottom: 4rem;
@@ -13,7 +24,6 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
overflow-y: auto;
}
.timeSettings {
@@ -52,7 +52,7 @@ export default function RundownEntryEditor() {
if (isOntimeEvent(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} />
</div>
@@ -61,7 +61,7 @@ export default function RundownEntryEditor() {
if (isOntimeMilestone(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.rundownEditor} data-testid='editor-container'>
<MilestoneEditor milestone={entry} />
</div>
);
@@ -69,7 +69,7 @@ export default function RundownEntryEditor() {
if (isOntimeGroup(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.rundownEditor} data-testid='editor-container'>
<GroupEditor group={entry} />
</div>
);
@@ -1,7 +1,7 @@
.triggerForm {
padding-block: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto auto;
grid-template-columns: 8rem 1fr auto 2rem;
gap: 0.5rem;
align-items: center;
}
@@ -9,7 +9,7 @@
.trigger {
padding: 0.25rem 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
grid-template-columns: 8rem 1fr 2rem;
align-items: center;
&:nth-child(even) {
@@ -155,7 +155,7 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
<IoTrash />
</IconButton>
</div>
@@ -6,6 +6,7 @@
height: 1px;
background: $blue-500;
z-index: $zindex-floating;
}
.addButton {
@@ -9,68 +9,53 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import style from './QuickAddInline.module.scss';
interface QuickAddInlineProps {
previousEventId: MaybeString;
referenceEntryId: MaybeString;
parentGroup: MaybeString;
placement: 'before' | 'after';
}
export default memo(QuickAddInline);
function QuickAddInline({ previousEventId, parentGroup }: QuickAddInlineProps) {
function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) {
const { addEntry } = useEntryActions();
const addEvent = () => {
addEntry(
{
type: SupportedEntry.Event,
parent: parentGroup,
},
{
after: previousEventId,
lastEventId: previousEventId,
},
);
};
const addDelay = () => {
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;
const handleAddEntry = (type: SupportedEntry) => {
if (placement === 'before') {
addEntry(
{ type, parent: type !== SupportedEntry.Group ? parentGroup : null },
{
before: referenceEntryId,
},
);
} else {
addEntry(
{ type, parent: type !== SupportedEntry.Group ? parentGroup : null },
{
lastEventId: referenceEntryId,
after: referenceEntryId,
},
);
}
addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
return (
<div className={style.quickAdd} data-testid='quick-add-inline'>
<DropdownMenu
items={[
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: addEvent },
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: addDelay },
{ 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 Event', onClick: () => handleAddEntry(SupportedEntry.Event) },
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: () => handleAddEntry(SupportedEntry.Delay) },
{
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} />}
>
@@ -12,12 +12,12 @@ import {
import { TbFlagFilled } from 'react-icons/tb';
import { useSortable } from '@dnd-kit/sortable';
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 { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
@@ -56,15 +56,7 @@ interface RundownEventProps {
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
createCloneEvent: () => void;
hasTriggers: boolean;
}
@@ -98,11 +90,13 @@ export default function RundownEvent({
dayOffset,
totalGap,
isLinkedToLoaded,
actionHandler,
hasTriggers,
createCloneEvent,
}: RundownEventProps) {
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 [isVisible, setIsVisible] = useState(false);
@@ -113,37 +107,48 @@ export default function RundownEvent({
type: 'item',
label: 'Link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: 'true',
}),
onClick: () => {
batchUpdateEvents({ linkStart: true }, Array.from(selectedEvents));
},
},
{
type: 'item',
label: 'Unlink from previous',
icon: IoUnlink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: null,
}),
onClick: () => {
batchUpdateEvents({ linkStart: false }, Array.from(selectedEvents));
},
},
{ 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: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
{
type: 'item',
label: 'Delete',
icon: IoTrash,
onClick: () => {
clearSelectedEvents();
deleteEntry(Array.from(selectedEvents));
},
},
]
: [
{
type: 'item',
label: flag ? 'Remove flag' : 'Add flag',
icon: TbFlagFilled,
onClick: () =>
actionHandler('update', {
field: 'flag',
value: !flag,
}),
onClick: () => {
updateEntry({ id: eventId, flag: !flag });
},
},
{ type: 'divider' },
{
@@ -157,7 +162,8 @@ export default function RundownEvent({
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
if (!selectedEventId) return;
swapEvents(selectedEventId, eventId);
clearSelectedEventId();
},
disabled: selectedEventId == null || selectedEventId === eventId,
@@ -166,10 +172,18 @@ export default function RundownEvent({
type: 'item',
label: 'Clone',
icon: IoDuplicateOutline,
onClick: () => actionHandler('clone'),
onClick: createCloneEvent,
},
{ 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 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] = (() => {
if (data.targetDuration === null) {
@@ -3,14 +3,13 @@
.milestone {
@include block-styling;
margin-left: calc(2rem + 1px); // binder + border
margin-block: 0.125rem;
padding-right: 0.25rem;
background-color: $gray-1050; // to override inline
color: $section-white; // to override inline
display: grid;
grid-template-columns: 2rem 1fr 3fr;
grid-template-columns: 2rem 0.4fr 1fr;
align-items: center;
height: $secondary-block-height;
gap: 0.5rem;
+11 -127
View File
@@ -1,129 +1,4 @@
import {
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;
}
import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
/**
* 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
* 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
if (targetType === 'end-group') {
return false;
}
// 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 (order !== undefined && order === 'after' && !isTargetCollapsed) {
return false;
}
return true;
}
+3 -3
View File
@@ -40,8 +40,8 @@
}
$track-color: $white-1;
$thumb-color: $gray-1100;
$thumb-color-hover: $gray-900;
$thumb-color: $white-20;
$thumb-color-hover: $white-60;
/* Apply a natural box layout model to all elements */
html {
@@ -157,7 +157,7 @@ input[type='number'] {
/* Track */
::-webkit-scrollbar-track {
background: $white-1;
background: $track-color;
border-radius: 2px;
}
@@ -1,9 +1,11 @@
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 { langDe } from './languages/de';
import { langEn } from './languages/en';
import { langEs } from './languages/es';
import { langFr } from './languages/fr';
import { langIt } from './languages/it';
@@ -21,15 +23,20 @@ const translationsList = {
export type TranslationKey = keyof typeof langEn;
interface TranslationContextValue {
userTranslation: TranslationObject;
getLocalizedString: (key: TranslationKey, lang?: string) => string;
postUserTranslation: (translation: TranslationObject) => Promise<void>;
}
const TranslationContext = createContext<TranslationContextValue>({
userTranslation: langEn,
getLocalizedString: () => '',
postUserTranslation: async () => {},
});
export const TranslationProvider = ({ children }: PropsWithChildren) => {
const { data } = useSettings();
const { data: translationData } = useCustomTranslation();
const getLocalizedString = useCallback(
(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]) {
return translationsList[lang as keyof typeof translationsList][key];
}
} else if (lang === 'custom') {
return translationData[key];
}
return langEn[key];
},
[data?.language],
[data?.language, translationData],
);
const contextValue = {
userTranslation: translationData,
getLocalizedString,
postUserTranslation,
};
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
};
export const useTranslation = () => {
const { getLocalizedString } = useContext(TranslationContext);
return { getLocalizedString };
const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext);
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 = {
'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 = {
'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 = {
'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 = {
'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 = {
'common.expected_finish': 'Término esperado',
@@ -18,6 +18,7 @@ import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils';
import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown';
import { CountdownData, useCountdownData } from './useCountdownData';
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} />;
}
@@ -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 { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -15,7 +14,6 @@ import { useCuesheetPermissions } from './useTablePermissions';
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() {
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const preset = use(PresetContext);
@@ -52,15 +50,11 @@ function CuesheetTableWrapper() {
[customFields, cuesheetMode, preset],
);
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable data={flatRundown} columns={columns} cuesheetMode={cuesheetMode} />
)}
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
</CuesheetDnd>
);
}
@@ -9,12 +9,12 @@ import {
useSensors,
} from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import useColumnManager from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps {
columns: ColumnDef<OntimeEntry>[];
columns: ColumnDef<ExtendedEntry>[];
}
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
@@ -1,20 +1,22 @@
$table-font-size: 1rem;
$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 {
font-size: $table-font-size;
font-weight: 400;
color: $ui-white;
padding-bottom: 70vh; // allow focus to reach last elements
thead {
tr {
&::before {
content: '';
display: block;
width: 4px;
height: 100%;
}
}
}
tr {
display: flex;
@@ -30,10 +32,6 @@ $table-header-font-size: calc(1rem - 2px);
width: 0.5rem;
}
}
&:first-of-type {
margin-left: 4px; // compensate left border
}
}
th,
@@ -82,7 +80,7 @@ $table-header-font-size: calc(1rem - 2px);
.actionColumn {
background-color: $gray-1250;
width: calc(2rem + 1px); // button + padding + margin
width: 2rem; // button + padding + margin
}
.indexColumn {
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 { 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 { 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 { usePersistedCuesheetOptions } from '../cuesheet.options';
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
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 CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import useColumnManager from './useColumnManager';
@@ -17,19 +25,20 @@ import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
data: OntimeEntry[];
columns: ColumnDef<OntimeEntry>[];
columns: ColumnDef<ExtendedEntry>[];
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 showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
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 meta = useMemo(
@@ -96,9 +105,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
setColumnSizing({});
}, [setColumnSizing]);
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
// in run mode, we follow the selected row
useEffect(() => {
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
@@ -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
}, [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 (
<>
<CuesheetTableSettings
@@ -126,25 +150,97 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
<div className={style.cuesheetContainer} ref={scrollRef}>
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
<CuesheetHeader headerGroups={headerGroups} cuesheetMode={cuesheetMode} />
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
) : (
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
)}
</table>
</div>
<TableVirtuoso
ref={virtuosoRef}
data={data}
increaseViewportBy={{ top: 100, bottom: 200 }}
components={{
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
Table: ({ style: injectedStyles, ...virtuosoProps }) => {
return (
<table
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 />
</>
);
}
/**
* 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 { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
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 { AppMode } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
@@ -12,54 +12,45 @@ import { SortableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeEntry>[];
headerGroup: HeaderGroup<ExtendedEntry>;
cuesheetMode: AppMode;
}
export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) {
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return (
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
<tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
{!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 (
<tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
{!hideIndexColumn && (
<th className={style.indexColumn} tabIndex={-1}>
#
</th>
)}
<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 = {
opacity: canWrite ? 1 : 0.6,
};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles.backgroundColor = customColour.backgroundColor;
customStyles.color = customColour.color;
}
const customStyles: CSSProperties = {
opacity: canWrite ? 1 : 0.6,
};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles.backgroundColor = customColour.backgroundColor;
customStyles.color = customColour.color;
}
return (
<SortableCell
key={header.column.columnDef.id}
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>
return (
<SortableCell
key={header.column.columnDef.id}
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>
);
}
@@ -1,15 +1,13 @@
@import '../CuesheetTable.module.scss';
.delayRow {
width: calc(100vw - 2rem);
color: $ontime-delay-text;
border-left: 4px solid var(--user-bg);
border-left: 4px solid transparent;
td {
width: 100%;
width: calc(100% - 4px);
padding-block: 0.5rem;
text-align: center;
transform: translateX(45%);
&:first-letter {
text-transform: uppercase;
@@ -1,28 +1,26 @@
import { memo } from 'react';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import style from './DelayRow.module.scss';
interface DelayRowProps {
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');
return (
<tr
className={style.delayRow}
style={{
'--user-bg': parentBgColour ?? 'transparent',
}}
data-testid='cuesheet-delay'
>
<td tabIndex={0} role='cell'>
{delayTime}
</td>
<tr className={style.delayRow} data-testid='cuesheet-delay' {...virtuosoProps}>
<td tabIndex={0}>{delayTime}</td>
</tr>
);
}
@@ -12,7 +12,7 @@
}
&.firstAfterGroup {
margin-top: 1rem;
margin-top: 2rem;
}
&.skip {
@@ -1,89 +1,91 @@
import { RefObject, useEffect, useRef } from 'react';
import { useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
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 IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import { observeRow, unobserveRow } from './rowObserver';
import { useVisibleRowsStore } from './visibleRowsStore';
import style from './EventRow.module.scss';
interface EventRowProps {
rowId: string;
event: OntimeEvent;
id: EntryId;
eventIndex: number;
colour: string;
isFirstAfterGroup: boolean;
isLoaded: boolean;
isPast: boolean;
groupColour: string | undefined;
flag: boolean;
skip: boolean;
parent: EntryId | null;
rowIndex: number;
isPast?: boolean;
selectedRef?: RefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
rowBgColour?: string;
parentBgColour?: string;
table: Table<OntimeEntry>;
firstAfterGroup: boolean;
table: Table<ExtendedEntry<OntimeEntry>>;
}
export default function EventRow({
rowId,
event,
id,
eventIndex,
rowIndex,
colour,
isFirstAfterGroup,
isLoaded,
isPast,
selectedRef,
rowBgColour,
parentBgColour,
groupColour,
flag,
skip,
parent,
rowIndex,
table,
firstAfterGroup,
...virtuosoProps
}: EventRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit,
hideIndexColumn: false,
};
const ownRef = useRef<HTMLTableRowElement>(null);
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
// register this row with the intersection observer
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 { color, backgroundColor } = getAccessibleColour(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 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 (
<tr
id={rowId}
className={cx([
style.eventRow,
event.skip && style.skip,
firstAfterGroup && style.firstAfterGroup,
Boolean(parentBgColour) && style.hasParent,
skip && style.skip,
isFirstAfterGroup && style.firstAfterGroup,
parent && style.hasParent,
])}
style={{
opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent',
'--user-bg': groupColour ?? 'transparent',
}}
ref={selectedRef ?? ownRef}
data-testid='cuesheet-event'
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
@@ -94,7 +96,7 @@ export default function EventRow({
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
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 />
@@ -106,26 +108,24 @@ export default function EventRow({
{eventIndex}
</td>
)}
{isVisible
? table
.getRow(rowId)
.getVisibleCells()
.map((cell) => {
return (
<td
key={cell.id}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour,
}}
tabIndex={-1}
role='cell'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})
: null}
{table
.getRow(rowId)
.getVisibleCells()
.map((cell) => {
return (
<td
key={cell.id}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour,
}}
tabIndex={-1}
role='cell'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
@@ -1,7 +1,7 @@
@import '../CuesheetTable.module.scss';
.groupRow {
margin-top: 1rem;
margin-top: 2em;
width: 100%;
display: flex;
align-items: start;
@@ -1,9 +1,9 @@
import { IoEllipsisHorizontal } from 'react-icons/io5';
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 { useCurrentGroupId } from '../../../../common/hooks/useSocket';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
@@ -12,15 +12,12 @@ import style from './GroupRow.module.scss';
interface GroupRowProps {
groupId: EntryId;
colour: string;
hidePast: boolean;
rowId: string;
rowIndex: number;
table: Table<OntimeEntry>;
table: Table<ExtendedEntry>;
}
export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, table }: GroupRowProps) {
const { currentGroupId } = useCurrentGroupId();
export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit,
hideIndexColumn: false,
@@ -28,12 +25,8 @@ export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, t
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
if (hidePast && !currentGroupId) {
return null;
}
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 && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton
@@ -1,7 +1,7 @@
@import "../CuesheetTable.module.scss";
.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);
font-style: italic;
@@ -1,9 +1,11 @@
import { IoEllipsisHorizontal } from 'react-icons/io5';
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 { 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 { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
@@ -12,12 +14,12 @@ import style from './MilestoneRow.module.scss';
interface MilestoneRowProps {
entryId: EntryId;
isPast: boolean;
parentBgColour: string | null;
parentBgColour?: string;
parentId: EntryId | null;
rowBgColour?: string;
colour: string;
rowId: string;
rowIndex: number;
table: Table<OntimeEntry>;
table: Table<ExtendedEntry>;
}
export default function MilestoneRow({
@@ -25,10 +27,11 @@ export default function MilestoneRow({
isPast,
parentBgColour,
parentId,
rowBgColour,
colour,
rowId,
rowIndex,
table,
...virtuosoProps
}: MilestoneRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit,
@@ -37,6 +40,18 @@ export default function MilestoneRow({
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 (
<tr
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
@@ -45,6 +60,7 @@ export default function MilestoneRow({
'--user-bg': parentBgColour ?? 'transparent',
}}
data-testid='cuesheet-milestone'
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
@@ -79,9 +95,9 @@ export default function MilestoneRow({
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour,
opacity: canRender ? 1 : 0.4,
}}
tabIndex={-1}
role='cell'
>
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
@@ -2,12 +2,13 @@ import { CSSProperties, ReactNode } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss';
interface SortableCellProps {
header: Header<OntimeEntry, unknown>;
header: Header<ExtendedEntry, unknown>;
injectedStyles: CSSProperties;
children: ReactNode;
}
@@ -34,11 +35,9 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
{children}
</div>
<div
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={style.resizer}
/>
</th>
@@ -1,9 +1,10 @@
import { useCallback } from 'react';
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 DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
@@ -16,7 +17,7 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
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) {
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) {
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) {
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(
(newValue: string) => {
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)
const initialValue = row.original[column.id as keyof OntimeEntry];
if (initialValue === undefined) {
const initialValue = row.original[column.id as keyof ExtendedEntry];
if (typeof initialValue !== 'string') {
return null;
}
@@ -148,7 +149,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
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(
(newValue: string) => {
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} />;
}
function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
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)
const initialValue = row.original[column.id as keyof OntimeEntry];
if (initialValue === undefined) {
const initialValue = row.original[column.id as keyof ExtendedEntry];
if (typeof initialValue !== 'string') {
return null;
}
@@ -188,7 +189,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
const event = row.original;
if (!isOntimeEvent(event) || !event.flag) {
return null;
@@ -196,7 +197,7 @@ function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
return <FlagCell />;
}
function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -229,8 +230,8 @@ export function makeCuesheetColumns(
customFields: CustomFields,
cuesheetMode: AppMode,
preset: URLPreset | undefined,
): ColumnDef<OntimeEntry>[] {
const columnsDef: ColumnDef<OntimeEntry>[] = [];
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
const fullRead = preset ? preset.options?.read === '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 { useSessionStorage } from '@mantine/hooks';
import type { Column } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import { PresetContext } from '../../../../common/context/PresetContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
@@ -23,7 +23,7 @@ import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss';
interface CuesheetTableSettingsProps {
columns: Column<OntimeEntry, unknown>[];
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
@@ -106,13 +106,6 @@ function ViewSettings() {
/>
Hide seconds in table
</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}>
<Checkbox
defaultChecked={options.hideIndexColumn}
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
const tableSizesKey = makeStageKey('cuesheet-sizes');
const tableHiddenKey = makeStageKey('cuesheet-hidden');
@@ -14,7 +14,7 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
}, 500);
export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey,
defaultValue: {},
@@ -4,7 +4,6 @@ import { persist } from 'zustand/middleware';
type OptionValues = {
hideTableSeconds: boolean;
hidePast: boolean;
hideIndexColumn: boolean;
showDelayedTimes: boolean;
hideDelays: boolean;
@@ -12,7 +11,6 @@ type OptionValues = {
const defaultOptions: OptionValues = {
hideTableSeconds: false,
hidePast: false,
hideIndexColumn: false,
showDelayedTimes: false,
hideDelays: false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.0.0-alpha.3",
"version": "4.0.0-alpha.5",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.0.0-alpha.3",
"version": "4.0.0-alpha.5",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -52,7 +52,7 @@
"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: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:electron": "node esbuild.electron.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();
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 });
@@ -98,7 +98,7 @@ class SocketServer implements IAdapter {
ws.on('close', () => {
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();
});
@@ -1,10 +1,11 @@
import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types';
import { validatePostCss } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js';
import { RefetchKey, type ErrorResponse } from 'ontime-types';
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
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 });
}
});
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 { readFile, writeFile } from 'node:fs/promises';
import { publicFiles } from '../../setup/index.js';
import { defaultCss } from '../../user/styles/bundledCss.js';
/**
@@ -31,3 +34,13 @@ export async function writeCssFile(css: string) {
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 { requestValidationFunction } from '../validation-utils/validationFunction.js';
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 type { Request, Response } from 'express';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
@@ -11,37 +13,52 @@ import { validateCustomField, validateDeleteCustomField, validateEditCustomField
export const router = express.Router();
/**
* Gets all the custom fields for the project
*/
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields();
res.status(200).json(customFields);
});
/**
* Creates a new custom field
*/
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const newFields = await createCustomField(req.body as CustomField);
res.status(201).send(newFields);
res.status(201).json(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
/**
* Modifies the properties of an existing custom field
*/
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const currentKey = req.params.key;
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) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
/**
* Deletes an existing custom field
*/
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const customFields = await deleteCustomField(req.params.key);
res.status(200).send(customFields);
const projectRundowns = getDataProvider().getProjectRundowns();
const customFields = await deleteCustomField(req.params.key, projectRundowns);
res.status(200).json(customFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
-1
View File
@@ -26,7 +26,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
errors: ParsingError[];
migrated: boolean;
} {
//TODO: TEST THIS!!!!!!!
let migrated = false;
let migratedData = jsonData;
if (v3.shouldUseThisMigration(jsonData)) {
@@ -1,7 +1,7 @@
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { CustomFields, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
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';
@@ -156,11 +156,38 @@ describe('parseExcel()', () => {
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 = [
['Title', 'Timer type'],
['a group', 'group'],
['an event', 'clock'],
['an event', 'clock'],
['an event', 'clock'],
['a second group ', 'group'],
['an event', 'clock'],
['an event', 'clock'],
];
const importMap = {
@@ -168,10 +195,17 @@ describe('parseExcel()', () => {
timerType: 'timer type',
};
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((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', () => {
@@ -314,8 +348,10 @@ describe('parseExcel()', () => {
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(6);
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']);
expect(result.rundown.order.length).toBe(5);
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({
A: {
@@ -417,162 +453,41 @@ describe('parseExcel()', () => {
linkStart: true,
});
});
});
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
it('handles milestones', () => {
const testdata = [
['Title', 'type', 'notes'],
['event...', 'count-down', ''],
['also event...', 'count-down', ''],
['this i a milestone', 'milestone', 'milestone note'],
];
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',
timerType: 'type',
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',
},
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
const secondEvent = result.rundown.entries[result.rundown.order[1]];
const milestone = result.rundown.entries[result.rundown.order[2]];
expect(result.rundown.order.length).toBe(3);
expect(firstEvent).toMatchObject({
type: SupportedEntry.Event,
timerType: TimerType.CountDown,
});
// 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',
},
expect(secondEvent).toMatchObject({
type: SupportedEntry.Event,
timerType: TimerType.CountDown,
});
// 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',
expect(milestone).toMatchObject({
type: SupportedEntry.Milestone,
title: 'this i a milestone',
note: 'milestone note',
});
});
});
@@ -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,
isOntimeGroup,
TimerType,
CustomFieldKey,
OntimeMilestone,
OntimeEntry,
isOntimeMilestone,
} from 'ontime-types';
import {
ImportMap,
@@ -16,22 +18,26 @@ import {
isKnownTimerType,
validateTimerType,
validateEndAction,
customFieldLabelToKey,
checkRegex,
} from 'ontime-utils';
import { Merge } from 'ts-essentials';
import { Prettify } from 'ts-essentials';
import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.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
* @param {array} excelData - array with excel sheet
* @param {ImportOptions} options - an object that contains the import map
* @returns {object} - parsed object
* TODO: import milestones
*/
export const parseExcel = (
excelData: unknown[][],
@@ -41,9 +47,8 @@ export const parseExcel = (
): {
rundown: Rundown;
customFields: CustomFields;
rundownMetadata: Record<string, { row: number; col: number }>;
sheetMetadata: SheetMetadata;
} => {
const rundownMetadata: Record<string, { row: number; col: number }> = {};
const importMap: ImportMap = { ...defaultImportMap, ...options };
for (const [key, value] of Object.entries(importMap)) {
@@ -63,166 +68,74 @@ export const parseExcel = (
revision: 0,
};
// title stuff: strings
let titleIndex: number | null = null;
let cueIndex: number | null = null;
let notesIndex: number | null = null;
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> = {};
// for placing entries into groups
let currentGroupId: string | null = null;
const groupEntries: string[] = [];
const { handlers, indexMap, sheetMetadata } = generateImportHandlers(importMap);
excelData.forEach((row, rowIndex) => {
if (row.length === 0) {
return;
}
// TODO: extract generating handlers from importMap
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 };
},
const entry: Partial<MergedOntimeEntry> = {};
[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 = {};
for (let j = 0; j < row.length; j++) {
const column = row[j];
// 1. we check if we have set a flag for a known field
if (j === timerTypeIndex) {
const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'group') {
// we leave this as a clue for the object filtering later on
if (j === indexMap.timerType) {
const maybeTimeType = makeString(column, '').toLowerCase();
if (maybeTimeType === 'group' || maybeTimeType === 'group-start') {
entry.type = SupportedEntry.Group;
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)) {
// @ts-expect-error -- we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Event;
entry.timerType = validateTimerType(maybeTimeType);
} else {
// if it is not a group or a known type, we dont import it
return;
}
} else if (j === titleIndex) {
} else if (j === indexMap.title) {
entry.title = makeString(column, '');
} else if (j === timeStartIndex) {
} else if (j === indexMap.timeStart) {
entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) {
} else if (j === indexMap.linkStart) {
entry.linkStart = parseBooleanString(column);
} else if (j === timeEndIndex) {
} else if (j === indexMap.timeEnd) {
entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
} else if (j === indexMap.duration) {
entry.duration = parseExcelDate(column);
} else if (j === cueIndex) {
} else if (j === indexMap.cue) {
entry.cue = makeString(column, '');
} else if (j === flagIndex) {
} else if (j === indexMap.flag) {
entry.flag = parseBooleanString(column);
} else if (j === countToEndIndex) {
} else if (j === indexMap.countToEnd) {
entry.countToEnd = parseBooleanString(column);
} else if (j === skipIndex) {
} else if (j === indexMap.skip) {
entry.skip = parseBooleanString(column);
} else if (j === notesIndex) {
} else if (j === indexMap.note) {
entry.note = makeString(column, '');
} else if (j === endActionIndex) {
} else if (j === indexMap.endAction) {
entry.endAction = validateEndAction(column);
} else if (j === timeWarningIndex) {
} else if (j === indexMap.timeWarning) {
entry.timeWarning = parseExcelDate(column);
} else if (j === timeDangerIndex) {
} else if (j === indexMap.timeDanger) {
entry.timeDanger = parseExcelDate(column);
} else if (j === colourIndex) {
} else if (j === indexMap.colour) {
entry.colour = makeString(column, '');
} else if (j === entryIdIndex) {
} else if (j === indexMap.entryId) {
entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in customFieldIndexes) {
const importKey = customFieldIndexes[j];
} else if (j in indexMap.custom) {
const importKey = indexMap.custom[j];
const ontimeKey = customFieldImportKeys[importKey];
entryCustomFields[ontimeKey] = makeString(column, '');
} else {
@@ -259,92 +172,78 @@ export const parseExcel = (
}
const id = entry.id || generateId();
// from excel, we can only get groups, milestones and events
if (isOntimeGroup(entry)) {
const group: OntimeGroup = { ...entry, custom: { ...entryCustomFields } };
rundown.order.push(id);
rundown.entries[id] = group;
if (entry.type === 'group-end') {
if (currentGroupId) {
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
currentGroupId = null;
}
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 = {
...entry,
custom: { ...entryCustomFields },
type: SupportedEntry.Event,
} as OntimeEvent;
if (timerTypeIndex === null) {
if (indexMap.timerType === null) {
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.entries[id] = event;
});
if (currentGroupId) {
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
}
return {
rundown,
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 dbRouter } from './db/db.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 sheetsRouter } from './sheets/sheets.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('/db', dbRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter);
appRouter.use('/rundowns', rundownsRouter);
appRouter.use('/settings', settingsRouter);
appRouter.use('/sheets', sheetsRouter);
appRouter.use('/excel', excelRouter);
@@ -30,7 +30,7 @@ appRouter.use('/view-settings', viewSettingsRouter);
appRouter.use('/report', reportRouter);
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) => {
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 {
@@ -7,6 +7,7 @@ import {
makeOntimeGroup,
makeOntimeDelay,
makeCustomField,
makeOntimeMilestone,
} from '../__mocks__/rundown.mocks.js';
import {
@@ -17,7 +18,6 @@ import {
rundownMutation,
} from '../rundown.dao.js';
import { demoDb } from '../../../models/demoProject.js';
import type { AssignedMap } from '../rundown.types.js';
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
const setRundownMock = vi.fn();
@@ -554,10 +554,6 @@ describe('processRundown()', () => {
});
const initResult = processRundown(rundown, customProperties);
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['2'] as OntimeEvent).custom).toMatchObject({
lighting: 'event 2 lx',
@@ -1742,22 +1738,26 @@ describe('customFieldMutation.renameUsages()', () => {
},
});
const assigned: AssignedMap = {
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one');
customFieldMutation.renameUsages(rundown, 'one', 'new-one');
expect(rundown.entries).toMatchObject({
'1': { id: '1', custom: { 'new-one': 'value1' } },
'2': { id: '2', custom: { 'new-one': 'value2' } },
'3': { id: '3', custom: { two: 'value3' } },
});
});
expect(assigned).toStrictEqual({
'new-one': ['1', '2'],
two: ['3'],
it('renames usages inside groups and milestones', () => {
const rundown = makeRundown({
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 = {
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.removeUsages(rundown, assigned, 'one');
customFieldMutation.removeUsages(rundown, 'one');
expect((rundown.entries['1'] 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 { 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()', () => {
it('returns a default project rundown if nothing is given', () => {
@@ -293,20 +293,8 @@ describe('parseRundown()', () => {
});
});
describe('addToCustomAssignment()', () => {
it('adds given entry to assignedCustomFields', () => {
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', () => {
describe('sanitiseCustomFields()', () => {
it('deletes unused custom fields', () => {
const customFields = {
lighting: {
type: 'text',
@@ -327,13 +315,11 @@ describe('handleCustomField()', () => {
linkStart: true,
custom: {
lighting: 'on',
unknown: 'does-not-exist',
},
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
sanitiseCustomFields(customFields, event);
expect(event.custom).toStrictEqual({
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 type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneGroup,
@@ -67,15 +67,6 @@ let rundownMetadata: RundownMetadata = {
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
* Not unique to the loaded rundown
@@ -89,7 +80,6 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac
type Transaction = {
customFields: CustomFields;
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
rundown: Rundown;
rundownMetadata: Readonly<RundownMetadata>;
@@ -136,13 +126,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
const processedData = processRundown(rundown, projectCustomFields);
// update the cache values
// 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 } =
processedData;
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder;
customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata;
}
}
@@ -167,7 +155,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
return {
customFields,
customFieldsMetadata,
rundown,
rundownMetadata,
commit,
@@ -564,6 +551,15 @@ export const rundownMutation = {
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
*/
@@ -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(
rundown: Rundown,
assigned: AssignedMap,
oldKey: CustomFieldKey,
newKey: CustomFieldKey,
) {
const usages = assigned[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];
function customFieldRenameUsages(rundown: Rundown, oldKey: CustomFieldKey, newKey: CustomFieldKey) {
Object.keys(rundown.entries).forEach((entryId) => {
const entry = rundown.entries[entryId];
if ('custom' in entry && entry.custom[oldKey]) {
// copy the data a new key and delete the old key
entry.custom[newKey] = entry.custom[oldKey];
delete entry.custom[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) {
const usages = assigned[key];
if (!usages) {
return;
}
// 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];
function customFieldRemoveUsages(rundown: Rundown, key: CustomFieldKey) {
Object.keys(rundown.entries).forEach((entryId) => {
const entry = rundown.entries[entryId];
if ('custom' in entry && entry.custom[key]) {
delete entry.custom[key];
}
});
}
export const customFieldMutation = {
@@ -672,13 +646,11 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
projectCustomFields = customFields;
// 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 } =
processedData;
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder;
cachedRundown.revision = rundown.revision;
customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata;
// defer writing to the database
@@ -14,6 +14,8 @@ import {
RundownEntries,
isPlayableEvent,
isOntimeMilestone,
OntimeMilestone,
OntimeGroup,
} from 'ontime-types';
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
* @param label
* @param eventId
* Ensures that custom fields have references
* If a field is exists in the entry but not in the project customFields, it is deleted
* Mutates the given event in place
*/
export function addToCustomAssignment(
key: CustomFieldKey,
eventId: EntryId,
assignedCustomFields: Record<string, string[]>,
) {
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];
}
export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
for (const field in entry.custom) {
if (field in customFields) continue;
delete entry.custom[field];
}
return entry;
}
export type ProcessedRundownMetadata = RundownMetadata & {
@@ -292,7 +270,7 @@ function processEntry<T extends OntimeEntry>(
}
// 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields);
sanitiseCustomFields(customFields, currentEntry);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays;

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