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

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