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