mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d2d8ef979 | |||
| ae15f3cdc5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.0.0-alpha.4",
|
"version": "4.0.0-alpha.5",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.0.0-alpha.4",
|
"version": "4.0.0-alpha.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router": "^7.8.0",
|
"react-router": "^7.8.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
"react-virtuoso": "^4.14.0",
|
||||||
"web-vitals": "^5.1.0",
|
"web-vitals": "^5.1.0",
|
||||||
"zustand": "^5.0.7"
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
|
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||||
|
|
||||||
const assetsPath = `${apiEntryUrl}/assets`;
|
const assetsPath = `${apiEntryUrl}/assets`;
|
||||||
|
|
||||||
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
|
|||||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to get user translation
|
||||||
|
*/
|
||||||
|
export async function getUserTranslation(): Promise<TranslationObject> {
|
||||||
|
const res = await axios.get(customTranslationsURL);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to post user translation
|
||||||
|
*/
|
||||||
|
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
|
||||||
|
await axios.post(`${assetsPath}/translations`, {
|
||||||
|
translation,
|
||||||
|
});
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
export const REPORT = ['report'];
|
export const REPORT = ['report'];
|
||||||
|
export const TRANSLATION = ['translation'];
|
||||||
|
|
||||||
// API URLs
|
// API URLs
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
const userAssetsPath = 'user';
|
const userAssetsPath = 'user';
|
||||||
const cssOverridePath = 'styles/override.css';
|
const cssOverridePath = 'styles/override.css';
|
||||||
|
const customTranslationsPath = 'translations/translations.json';
|
||||||
|
|
||||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
|
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import {
|
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
||||||
EntryId,
|
|
||||||
MessageResponse,
|
|
||||||
OntimeEntry,
|
|
||||||
OntimeEvent,
|
|
||||||
ProjectRundownsList,
|
|
||||||
Rundown,
|
|
||||||
TransientEventPayload,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
const rundownPath = `${apiEntryUrl}/rundown`;
|
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||||
|
|
||||||
|
// #region operations on project rundowns =========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch a list of existing rundowns
|
* HTTP request to fetch a list of existing rundowns
|
||||||
@@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch all events
|
* HTTP request to fetch all entries in the currently loaded rundown
|
||||||
*/
|
*/
|
||||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||||
const res = await axios.get(`${rundownPath}/current`);
|
const res = await axios.get(`${rundownPath}/current`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to switch the currently loaded rundown
|
||||||
|
*/
|
||||||
|
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(`${rundownPath}/${id}/load`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to create a new rundown
|
||||||
|
*/
|
||||||
|
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(rundownPath, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to delete a rundown
|
||||||
|
*/
|
||||||
|
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.delete(`${rundownPath}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #endregion operations on project rundowns ======================
|
||||||
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to post new entry
|
* HTTP request to post new entry
|
||||||
*/
|
*/
|
||||||
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
export async function postAddEntry(
|
||||||
return axios.post(rundownPath, data);
|
rundownId: string,
|
||||||
|
data: TransientEventPayload,
|
||||||
|
): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to edit an entry
|
* HTTP request to edit an entry
|
||||||
*/
|
*/
|
||||||
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
return axios.put(rundownPath, data);
|
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchEditEntry = {
|
export type BatchEditEntry = {
|
||||||
data: Partial<OntimeEvent>;
|
data: Partial<OntimeEvent>;
|
||||||
ids: string[];
|
ids: EntryId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to edit multiple events
|
* HTTP request to edit multiple events
|
||||||
*/
|
*/
|
||||||
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.put(`${rundownPath}/batch`, data);
|
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReorderEntry = {
|
export type ReorderEntry = {
|
||||||
@@ -64,60 +85,57 @@ export type ReorderEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to reorder an entry
|
* HTTP request to reorder an entry
|
||||||
*/
|
*/
|
||||||
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/reorder`, data);
|
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SwapEntry = {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to swap two events
|
* HTTP request to swap two events
|
||||||
*/
|
*/
|
||||||
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
|
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/swap`, data);
|
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to request application of delay
|
* HTTP request to request application of delay
|
||||||
*/
|
*/
|
||||||
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
|
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for cloning an entry
|
* HTTP request for cloning an entry
|
||||||
*/
|
*/
|
||||||
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/clone/${entryId}`);
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP request for dissolving of a group
|
|
||||||
*/
|
|
||||||
export async function requestUngroup(groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
|
||||||
return axios.post(`${rundownPath}/ungroup/${groupId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for grouping a list of entries into a group
|
* HTTP request for grouping a list of entries into a group
|
||||||
*/
|
*/
|
||||||
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/group`, { ids: entryIds });
|
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete entries
|
* HTTP request for dissolving of a group
|
||||||
*/
|
*/
|
||||||
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
|
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(rundownPath, { data: { ids: entryIds } });
|
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete all events
|
* HTTP request to delete entries of a given rundown
|
||||||
*/
|
*/
|
||||||
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
|
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(`${rundownPath}/all`);
|
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to delete all entries of a given rundown
|
||||||
|
*/
|
||||||
|
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
|
||||||
|
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||||
|
|
||||||
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
|
||||||
const { name, value } = props;
|
|
||||||
const [colour, setColour] = useState(() => ensureHex(value));
|
const [colour, setColour] = useState(() => ensureHex(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColour(ensureHex(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { ComponentProps, useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
import Checkbox from '../checkbox/Checkbox';
|
import Checkbox from '../checkbox/Checkbox';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
import Select from '../select/Select';
|
import Select, { SelectOption } from '../select/Select';
|
||||||
import Switch from '../switch/Switch';
|
import Switch from '../switch/Switch';
|
||||||
|
|
||||||
import InlineColourPicker from './InlineColourPicker';
|
import InlineColourPicker from './InlineColourPicker';
|
||||||
@@ -35,7 +35,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
return <span className={style.empty}>No options available</span>;
|
return <span className={style.empty}>No options available</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
|
return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'multi-option') {
|
if (type === 'multi-option') {
|
||||||
@@ -43,7 +43,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
|
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
@@ -63,15 +63,13 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'colour') {
|
if (type === 'colour') {
|
||||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
|
||||||
|
|
||||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
|
||||||
const { placeholder } = paramField;
|
const { placeholder } = paramField;
|
||||||
|
|
||||||
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
|
return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditFormMultiOptionProps {
|
interface EditFormMultiOptionProps {
|
||||||
@@ -83,7 +81,12 @@ function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
|||||||
const { id, values, defaultValue = [''] } = paramField;
|
const { id, values, defaultValue = [''] } = paramField;
|
||||||
|
|
||||||
const optionFromParams = searchParams.getAll(id);
|
const optionFromParams = searchParams.getAll(id);
|
||||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
|
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = searchParams.getAll(id);
|
||||||
|
setParamState(params.length ? params : defaultValue);
|
||||||
|
}, [searchParams, id, defaultValue]);
|
||||||
|
|
||||||
const toggleValue = (value: string, checked: boolean) => {
|
const toggleValue = (value: string, checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -129,5 +132,52 @@ interface ControlledSwitchProps {
|
|||||||
}
|
}
|
||||||
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
||||||
const [checked, setChecked] = useState(initialValue);
|
const [checked, setChecked] = useState(initialValue);
|
||||||
|
|
||||||
|
// synchronise checked state
|
||||||
|
useEffect(() => {
|
||||||
|
setChecked(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ControlledSelectProps {
|
||||||
|
id: string;
|
||||||
|
initialValue?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
|
||||||
|
const [selected, setSelected] = useState(initialValue);
|
||||||
|
|
||||||
|
// synchronise selected state
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
|
||||||
|
id: string;
|
||||||
|
initialValue: T;
|
||||||
|
}
|
||||||
|
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
// synchronise selected state
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
height='large'
|
||||||
|
name={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value as T)}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FormEvent, memo, useReducer } from 'react';
|
import { FormEvent, memo } from 'react';
|
||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui-components/react/dialog';
|
||||||
@@ -12,7 +12,7 @@ import Info from '../info/Info';
|
|||||||
import { ViewOption } from './viewParams.types';
|
import { ViewOption } from './viewParams.types';
|
||||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||||
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||||
import { ViewParamsShare } from './ViewParamShare';
|
import { ViewParamsPresets } from './ViewParamsPresets';
|
||||||
import ViewParamsSection from './ViewParamsSection';
|
import ViewParamsSection from './ViewParamsSection';
|
||||||
|
|
||||||
import style from './ViewParamsEditor.module.scss';
|
import style from './ViewParamsEditor.module.scss';
|
||||||
@@ -24,12 +24,9 @@ interface EditFormDrawerProps {
|
|||||||
|
|
||||||
export default memo(ViewParamsEditor);
|
export default memo(ViewParamsEditor);
|
||||||
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||||
// TODO: can we ensure that the options update when the user loads an alias?
|
|
||||||
const [_, setSearchParams] = useSearchParams();
|
const [_, setSearchParams] = useSearchParams();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
const { isOpen, close } = useViewParamsEditorStore();
|
const { isOpen, close } = useViewParamsEditorStore();
|
||||||
// TODO: we dont want this as a permanent option
|
|
||||||
const forceRender = useReducer((x) => x + 1, 0)[1];
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close();
|
close();
|
||||||
@@ -37,7 +34,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
|
|
||||||
const resetParams = () => {
|
const resetParams = () => {
|
||||||
setSearchParams();
|
setSearchParams();
|
||||||
forceRender();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||||
@@ -46,7 +42,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
forceRender();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +66,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
{viewSettings.overrideStyles && (
|
{viewSettings.overrideStyles && (
|
||||||
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
||||||
)}
|
)}
|
||||||
<ViewParamsShare target={target} />
|
<ViewParamsPresets target={target} />
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||||
{viewOptions.map((section) => (
|
{viewOptions.map((section) => (
|
||||||
<ViewParamsSection
|
<ViewParamsSection
|
||||||
|
|||||||
+3
@@ -5,6 +5,9 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
max-height: 10rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset {
|
.preset {
|
||||||
+8
-6
@@ -5,17 +5,19 @@ import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
|||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
|
|
||||||
import style from './ViewParamsShare.module.scss';
|
import style from './ViewParamsPresets.module.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a list of presets for the current view
|
* Shows a list of presets for the current view
|
||||||
*/
|
*/
|
||||||
export function ViewParamsShare({ target }: { target: OntimeView }) {
|
export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||||
const { viewPresets } = useViewUrlPresets(target);
|
const { viewPresets } = useViewUrlPresets(target);
|
||||||
const [_, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const handleRecall = (preset: URLPreset) => {
|
const handleRecall = (preset: URLPreset) => {
|
||||||
setSearchParams(`${preset.search}&alias=${preset.alias}`);
|
const newSearch = new URLSearchParams(preset.search);
|
||||||
|
newSearch.set('alias', preset.alias);
|
||||||
|
setSearchParams(newSearch);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (viewPresets.length === 0) {
|
if (viewPresets.length === 0) {
|
||||||
@@ -25,12 +27,12 @@ export function ViewParamsShare({ target }: { target: OntimeView }) {
|
|||||||
return (
|
return (
|
||||||
<div className={style.presetSection}>
|
<div className={style.presetSection}>
|
||||||
{viewPresets.map((preset) => {
|
{viewPresets.map((preset) => {
|
||||||
const active = window.location.search.includes(`alias=${preset.alias}`);
|
const active = searchParams.get('alias') === preset.alias;
|
||||||
return (
|
return (
|
||||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||||
<div>{preset.alias}</div>
|
<div>{preset.alias}</div>
|
||||||
<Button
|
<Button
|
||||||
variant='subtle-white'
|
variant={active ? 'ghosted' : 'subtle-white'}
|
||||||
onClick={() => handleRecall(preset)}
|
onClick={() => handleRecall(preset)}
|
||||||
disabled={active}
|
disabled={active}
|
||||||
className={style.presetActions}
|
className={style.presetActions}
|
||||||
@@ -44,3 +44,7 @@
|
|||||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -47,15 +47,11 @@ interface SectionContentsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||||
if (collapsed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
return (
|
return (
|
||||||
<label key={option.title} className={style.label}>
|
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||||
<span className={style.title}>{option.title}</span>
|
<span className={style.title}>{option.title}</span>
|
||||||
<span className={style.description}>{option.description}</span>
|
<span className={style.description}>{option.description}</span>
|
||||||
<ParamInput paramField={option} />
|
<ParamInput paramField={option} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
|
|||||||
type MultiOptionsField = {
|
type MultiOptionsField = {
|
||||||
type: 'multi-option';
|
type: 'multi-option';
|
||||||
values: MultiselectOption[];
|
values: MultiselectOption[];
|
||||||
defaultValue?: string;
|
defaultValue?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { langEn } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getUserTranslation } from '../../common/api/assets';
|
||||||
|
import { TRANSLATION } from '../../common/api/constants';
|
||||||
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
|
|
||||||
|
export function useCustomTranslation() {
|
||||||
|
const { data, status, refetch } = useQuery({
|
||||||
|
queryKey: TRANSLATION,
|
||||||
|
queryFn: getUserTranslation,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
});
|
||||||
|
return { data: data ?? langEn, status, refetch };
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ProjectRundownsList } from 'ontime-types';
|
import { ProjectRundownsList } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||||
import { fetchProjectRundownList } from '../api/rundown';
|
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project rundowns
|
* Project rundowns
|
||||||
@@ -13,10 +13,43 @@ export function useProjectRundowns() {
|
|||||||
queryKey: PROJECT_RUNDOWNS,
|
queryKey: PROJECT_RUNDOWNS,
|
||||||
queryFn: fetchProjectRundownList,
|
queryFn: fetchProjectRundownList,
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMutateProjectRundowns() {
|
||||||
|
const ontimeQueryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { mutateAsync: create } = useMutation({
|
||||||
|
mutationFn: createRundown,
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: remove } = useMutation({
|
||||||
|
mutationFn: deleteRundown,
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: load } = useMutation({
|
||||||
|
mutationFn: loadRundown,
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { create, remove, load };
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
|||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import { fetchCurrentRundown } from '../api/rundown';
|
import { fetchCurrentRundown } from '../api/rundown';
|
||||||
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
|
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||||
|
|
||||||
import useProjectData from './useProjectData';
|
import useProjectData from './useProjectData';
|
||||||
|
|
||||||
@@ -26,14 +28,18 @@ export default function useRundown() {
|
|||||||
queryKey: RUNDOWN,
|
queryKey: RUNDOWN,
|
||||||
queryFn: fetchCurrentRundown,
|
queryFn: fetchCurrentRundown,
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRundownWithMetadata() {
|
||||||
|
const { data, status } = useRundown();
|
||||||
|
const { selectedEventId } = useSelectedEventId();
|
||||||
|
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||||
|
return { data, status, rundownMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides access to a flat rundown
|
* Provides access to a flat rundown
|
||||||
* built from the order and rundown fields
|
* built from the order and rundown fields
|
||||||
@@ -68,6 +74,14 @@ export function useFlatRundown() {
|
|||||||
return { data: flatRundown, rundownId: data.id, status };
|
return { data: flatRundown, rundownId: data.id, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFlatRundownWithMetadata() {
|
||||||
|
const { data, status } = useRundown();
|
||||||
|
const { selectedEventId } = useSelectedEventId();
|
||||||
|
|
||||||
|
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||||
|
return { data: rundownWithMetadata, status };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides access to a partial rundown based on a filter callback
|
* Provides access to a partial rundown based on a filter callback
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
requestEventSwap,
|
requestEventSwap,
|
||||||
requestGroupEntries,
|
requestGroupEntries,
|
||||||
requestUngroup,
|
requestUngroup,
|
||||||
SwapEntry,
|
|
||||||
} from '../api/rundown';
|
} from '../api/rundown';
|
||||||
import { logAxiosError } from '../api/utils';
|
import { logAxiosError } from '../api/utils';
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
@@ -59,15 +58,25 @@ export const useEntryActions = () => {
|
|||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
} = useEditorSettings();
|
} = useEditorSettings();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently loaded rundown
|
||||||
|
*/
|
||||||
|
const getCurrentRundownData = useCallback(() => {
|
||||||
|
return queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks for an entry with a given ID in the currently loaded rundown
|
||||||
|
*/
|
||||||
const getEntryById = useCallback(
|
const getEntryById = useCallback(
|
||||||
(eventId: string): OntimeEntry | undefined => {
|
(eventId: EntryId): OntimeEntry | undefined => {
|
||||||
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
const cachedRundown = getCurrentRundownData();
|
||||||
if (!cachedRundown?.entries) {
|
if (!cachedRundown?.entries) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return cachedRundown.entries[eventId];
|
return cachedRundown.entries[eventId];
|
||||||
},
|
},
|
||||||
[queryClient],
|
[getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,8 +84,8 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: addEntryMutation } = useMutation({
|
const { mutateAsync: addEntryMutation } = useMutation({
|
||||||
// TODO(v4): optimistic create entry
|
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
|
||||||
mutationFn: postAddEntry,
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,13 +94,19 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const addEntry = useCallback(
|
const addEntry = useCallback(
|
||||||
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||||
|
const rundownData = getCurrentRundownData();
|
||||||
|
const rundownId = rundownData?.id;
|
||||||
|
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEntry)) {
|
if (isOntimeEvent(newEntry)) {
|
||||||
if (options?.lastEventId) {
|
if (options?.lastEventId) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
|
||||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||||
if (isOntimeEvent(previousEvent)) {
|
if (isOntimeEvent(previousEvent)) {
|
||||||
newEntry.timeStart = previousEvent.timeEnd;
|
newEntry.timeStart = previousEvent.timeEnd;
|
||||||
@@ -135,21 +150,21 @@ export const useEntryActions = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addEntryMutation(newEntry);
|
await addEntryMutation([rundownId, newEntry]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
addEntryMutation,
|
getCurrentRundownData,
|
||||||
defaultDangerTime,
|
|
||||||
defaultDuration,
|
|
||||||
defaultEndAction,
|
|
||||||
defaultTimerType,
|
|
||||||
defaultTimeStrategy,
|
|
||||||
defaultWarnTime,
|
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
queryClient,
|
defaultDuration,
|
||||||
|
defaultDangerTime,
|
||||||
|
defaultWarnTime,
|
||||||
|
defaultTimerType,
|
||||||
|
defaultEndAction,
|
||||||
|
defaultTimeStrategy,
|
||||||
|
addEntryMutation,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -158,22 +173,28 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: cloneEntryMutation } = useMutation({
|
const { mutateAsync: cloneEntryMutation } = useMutation({
|
||||||
mutationFn: postCloneEntry,
|
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
|
||||||
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone a selection
|
* Clone an entry
|
||||||
*/
|
*/
|
||||||
const clone = useCallback(
|
const clone = useCallback(
|
||||||
async (entryId: EntryId) => {
|
async (entryId: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await cloneEntryMutation(entryId);
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await cloneEntryMutation([rundownId, entryId]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error cloning entry', error);
|
logAxiosError('Error cloning entry', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cloneEntryMutation],
|
[cloneEntryMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,9 +202,9 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: updateEntryMutation } = useMutation({
|
const { mutateAsync: updateEntryMutation } = useMutation({
|
||||||
mutationFn: putEditEntry,
|
mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (newEvent) => {
|
onMutate: async ([_rundownId, newEvent]) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
@@ -211,7 +232,9 @@ export const useEntryActions = () => {
|
|||||||
},
|
},
|
||||||
// Mutation fails, rollback undoes optimist update
|
// Mutation fails, rollback undoes optimist update
|
||||||
onError: (_error, _newEvent, context) => {
|
onError: (_error, _newEvent, context) => {
|
||||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
if (context?.previousData) {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
@@ -226,19 +249,17 @@ export const useEntryActions = () => {
|
|||||||
const updateEntry = useCallback(
|
const updateEntry = useCallback(
|
||||||
async (entry: Partial<OntimeEntry>) => {
|
async (entry: Partial<OntimeEntry>) => {
|
||||||
try {
|
try {
|
||||||
await updateEntryMutation(entry);
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEntryMutation([rundownId, entry]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating event', error);
|
logAxiosError('Error updating event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateEntryMutation],
|
[getCurrentRundownData, updateEntryMutation],
|
||||||
);
|
|
||||||
|
|
||||||
const updateCustomField = useCallback(
|
|
||||||
async (entryId: EntryId, field: string, value: string) => {
|
|
||||||
updateEntry({ id: entryId, custom: { [field]: value } });
|
|
||||||
},
|
|
||||||
[updateEntry],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -250,6 +271,11 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const updateTimer = useCallback(
|
const updateTimer = useCallback(
|
||||||
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||||
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
// an empty value with no lock has no domain validity
|
// an empty value with no lock has no domain validity
|
||||||
if (!lockOnUpdate && value === '') {
|
if (!lockOnUpdate && value === '') {
|
||||||
return;
|
return;
|
||||||
@@ -279,7 +305,7 @@ export const useEntryActions = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateEntryMutation(newEvent);
|
await updateEntryMutation([rundownId, newEvent]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating event', error);
|
logAxiosError('Error updating event', error);
|
||||||
}
|
}
|
||||||
@@ -331,7 +357,7 @@ export const useEntryActions = () => {
|
|||||||
return previousEnd;
|
return previousEnd;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateEntryMutation, queryClient],
|
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,8 +365,8 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
|
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
|
||||||
mutationFn: putBatchEditEvents,
|
mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
|
||||||
onMutate: async ({ ids, data }) => {
|
onMutate: async ([_rundownId, data]) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
@@ -348,7 +374,7 @@ export const useEntryActions = () => {
|
|||||||
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
if (previousRundown) {
|
if (previousRundown) {
|
||||||
const eventIds = new Set(ids);
|
const eventIds = new Set(data.ids);
|
||||||
const newRundown = { ...previousRundown.entries };
|
const newRundown = { ...previousRundown.entries };
|
||||||
|
|
||||||
eventIds.forEach((eventId) => {
|
eventIds.forEach((eventId) => {
|
||||||
@@ -395,14 +421,19 @@ export const useEntryActions = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const batchUpdateEvents = useCallback(
|
const batchUpdateEvents = useCallback(
|
||||||
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
|
||||||
try {
|
try {
|
||||||
await batchUpdateEventsMutation({ ids: eventIds, data });
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await batchUpdateEventsMutation([rundownId, { data, ids: eventIds }]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating events', error);
|
logAxiosError('Error updating events', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[batchUpdateEventsMutation],
|
[batchUpdateEventsMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -410,9 +441,9 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: deleteEntryMutation } = useMutation({
|
const { mutateAsync: deleteEntryMutation } = useMutation({
|
||||||
mutationFn: deleteEntries,
|
mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (entryIds: EntryId[]) => {
|
onMutate: async ([_rundownId, entryIds]) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
@@ -454,12 +485,17 @@ export const useEntryActions = () => {
|
|||||||
const deleteEntry = useCallback(
|
const deleteEntry = useCallback(
|
||||||
async (entryIds: EntryId[]) => {
|
async (entryIds: EntryId[]) => {
|
||||||
try {
|
try {
|
||||||
await deleteEntryMutation(entryIds);
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteEntryMutation([rundownId, entryIds]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error deleting event', error);
|
logAxiosError('Error deleting event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deleteEntryMutation],
|
[deleteEntryMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -467,7 +503,7 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
|
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
|
||||||
mutationFn: requestDeleteAll,
|
mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async () => {
|
onMutate: async () => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
@@ -506,18 +542,24 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const deleteAllEntries = useCallback(async () => {
|
const deleteAllEntries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllEntriesMutation();
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAllEntriesMutation([rundownId]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error deleting events', error);
|
logAxiosError('Error deleting events', error);
|
||||||
}
|
}
|
||||||
}, [deleteAllEntriesMutation]);
|
}, [deleteAllEntriesMutation, getCurrentRundownData]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to apply a delay
|
* Calls mutation to apply a delay
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: applyDelayMutation } = useMutation({
|
const { mutateAsync: applyDelayMutation } = useMutation({
|
||||||
mutationFn: requestApplyDelay,
|
mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
|
||||||
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
|
|
||||||
@@ -543,12 +585,17 @@ export const useEntryActions = () => {
|
|||||||
const applyDelay = useCallback(
|
const applyDelay = useCallback(
|
||||||
async (delayEventId: EntryId) => {
|
async (delayEventId: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await applyDelayMutation(delayEventId);
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyDelayMutation([rundownId, delayEventId]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error applying delay', error);
|
logAxiosError('Error applying delay', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyDelayMutation],
|
[applyDelayMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -556,7 +603,8 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: ungroupMutation } = useMutation({
|
const { mutateAsync: ungroupMutation } = useMutation({
|
||||||
mutationFn: requestUngroup,
|
mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
|
||||||
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
|
|
||||||
@@ -579,12 +627,17 @@ export const useEntryActions = () => {
|
|||||||
const ungroup = useCallback(
|
const ungroup = useCallback(
|
||||||
async (groupId: EntryId) => {
|
async (groupId: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await ungroupMutation(groupId);
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ungroupMutation([rundownId, groupId]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error dissolving group', error);
|
logAxiosError('Error dissolving group', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ungroupMutation],
|
[getCurrentRundownData, ungroupMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -592,7 +645,9 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: groupEntriesMutation } = useMutation({
|
const { mutateAsync: groupEntriesMutation } = useMutation({
|
||||||
mutationFn: requestGroupEntries,
|
mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
|
||||||
|
requestGroupEntries(rundownId, entryIds),
|
||||||
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
|
|
||||||
@@ -617,19 +672,24 @@ export const useEntryActions = () => {
|
|||||||
if (entryIds.length === 0) return;
|
if (entryIds.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const rundownData = getCurrentRundownData();
|
||||||
|
const rundownId = rundownData?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
if (entryIds.length === 1) {
|
if (entryIds.length === 1) {
|
||||||
await groupEntriesMutation(entryIds);
|
await groupEntriesMutation([rundownId, entryIds]);
|
||||||
} else {
|
} else {
|
||||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
// the user selection may be out of order
|
||||||
if (!rundown) return;
|
const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
|
||||||
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
|
await groupEntriesMutation([rundownId, orderedIds]);
|
||||||
await groupEntriesMutation(orderedIds);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error grouping entries', error);
|
logAxiosError('Error grouping entries', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[groupEntriesMutation, queryClient],
|
[getCurrentRundownData, groupEntriesMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -637,9 +697,8 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: reorderEntryMutation } = useMutation({
|
const { mutateAsync: reorderEntryMutation } = useMutation({
|
||||||
mutationFn: patchReorderEntry,
|
mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
|
||||||
// Mutation finished, failed or successful
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
// Fetch anyway, just to be sure
|
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
@@ -650,34 +709,36 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const move = useCallback(
|
const move = useCallback(
|
||||||
async (entryId: EntryId, direction: 'up' | 'down') => {
|
async (entryId: EntryId, direction: 'up' | 'down') => {
|
||||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
|
||||||
if (!rundown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { destinationId, order } =
|
|
||||||
direction === 'up'
|
|
||||||
? moveUp(entryId, rundown.flatOrder, rundown.entries)
|
|
||||||
: moveDown(entryId, rundown.flatOrder, rundown.entries);
|
|
||||||
|
|
||||||
if (!destinationId) {
|
|
||||||
return; // noop
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const rundownData = getCurrentRundownData();
|
||||||
|
const rundownId = rundownData?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { destinationId, order } =
|
||||||
|
direction === 'up'
|
||||||
|
? moveUp(entryId, rundownData.flatOrder, rundownData.entries)
|
||||||
|
: moveDown(entryId, rundownData.flatOrder, rundownData.entries);
|
||||||
|
|
||||||
|
if (!destinationId) {
|
||||||
|
return; // noop
|
||||||
|
}
|
||||||
|
|
||||||
const reorderObject: ReorderEntry = {
|
const reorderObject: ReorderEntry = {
|
||||||
entryId,
|
entryId,
|
||||||
destinationId,
|
destinationId,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
await reorderEntryMutation(reorderObject);
|
await reorderEntryMutation([rundownId, reorderObject]);
|
||||||
|
// the rundown needs to know whether we moved into a group
|
||||||
|
return rundownData.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error re-ordering event', error);
|
logAxiosError('Error re-ordering event', error);
|
||||||
}
|
}
|
||||||
// the rundown needs to know whether we moved into a group
|
return undefined;
|
||||||
return rundown.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
|
|
||||||
},
|
},
|
||||||
[queryClient, reorderEntryMutation],
|
[getCurrentRundownData, reorderEntryMutation],
|
||||||
);
|
);
|
||||||
/**
|
/**
|
||||||
* Reorders a given entry
|
* Reorders a given entry
|
||||||
@@ -685,18 +746,25 @@ export const useEntryActions = () => {
|
|||||||
const reorderEntry = useCallback(
|
const reorderEntry = useCallback(
|
||||||
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
|
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
|
||||||
try {
|
try {
|
||||||
const reorderObject: ReorderEntry = {
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
entryId,
|
if (!rundownId) {
|
||||||
destinationId,
|
throw new Error('Rundown not initialised');
|
||||||
order,
|
}
|
||||||
};
|
|
||||||
await reorderEntryMutation(reorderObject);
|
await reorderEntryMutation([
|
||||||
|
rundownId,
|
||||||
|
{
|
||||||
|
entryId,
|
||||||
|
destinationId,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error re-ordering event', error);
|
logAxiosError('Error re-ordering event', error);
|
||||||
throw error; // rethrow to handle in the component
|
throw error; // rethrow to handle in the component
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[reorderEntryMutation],
|
[getCurrentRundownData, reorderEntryMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -704,9 +772,9 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: swapEventsMutation } = useMutation({
|
const { mutateAsync: swapEventsMutation } = useMutation({
|
||||||
mutationFn: requestEventSwap,
|
mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async ({ from, to }) => {
|
onMutate: async ([_rundownId, from, to]) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
@@ -755,14 +823,19 @@ export const useEntryActions = () => {
|
|||||||
* Swaps the schedule of two events
|
* Swaps the schedule of two events
|
||||||
*/
|
*/
|
||||||
const swapEvents = useCallback(
|
const swapEvents = useCallback(
|
||||||
async ({ from, to }: SwapEntry) => {
|
async (from: EntryId, to: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await swapEventsMutation({ from, to });
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await swapEventsMutation([rundownId, from, to]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error re-ordering event', error);
|
logAxiosError('Error re-ordering event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[swapEventsMutation],
|
[getCurrentRundownData, swapEventsMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -780,7 +853,6 @@ export const useEntryActions = () => {
|
|||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
updateCustomField,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import { initRundownMetadata } from '../rundownMetadata';
|
||||||
|
|
||||||
|
describe('initRundownMetadata()', () => {
|
||||||
|
it('processes nested rundown data', () => {
|
||||||
|
const selectedEventId = '12';
|
||||||
|
const demoEvents = {
|
||||||
|
'1': {
|
||||||
|
id: '1',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: null,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 1,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
group: {
|
||||||
|
id: 'group',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
entries: ['11', 'delay', '12', '13'],
|
||||||
|
colour: 'red',
|
||||||
|
} as OntimeGroup,
|
||||||
|
'11': {
|
||||||
|
id: '11',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 10,
|
||||||
|
timeEnd: 11,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 10,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
delay: {
|
||||||
|
id: 'delay',
|
||||||
|
type: SupportedEntry.Delay,
|
||||||
|
parent: 'group',
|
||||||
|
duration: 0,
|
||||||
|
} as OntimeDelay,
|
||||||
|
'12': {
|
||||||
|
id: '12',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 11,
|
||||||
|
timeEnd: 12,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: true,
|
||||||
|
} as OntimeEvent,
|
||||||
|
'13': {
|
||||||
|
id: '13',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 12,
|
||||||
|
timeEnd: 13,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: true,
|
||||||
|
} as OntimeEvent,
|
||||||
|
'2': {
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: null,
|
||||||
|
timeStart: 20,
|
||||||
|
timeEnd: 21,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 7,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { metadata, process } = initRundownMetadata(selectedEventId);
|
||||||
|
|
||||||
|
expect(metadata).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: null,
|
||||||
|
previousEntryId: null,
|
||||||
|
thisId: null,
|
||||||
|
eventIndex: 0,
|
||||||
|
isPast: true,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
|
groupEntries: undefined,
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['1'])).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: demoEvents['1'],
|
||||||
|
previousEntryId: null,
|
||||||
|
thisId: demoEvents['1'].id,
|
||||||
|
eventIndex: 1, // UI indexes are 1 based
|
||||||
|
isPast: true,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
|
groupEntries: undefined,
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['group'])).toMatchObject({
|
||||||
|
previousEvent: demoEvents['1'],
|
||||||
|
latestEvent: demoEvents['1'],
|
||||||
|
previousEntryId: demoEvents['1'].id,
|
||||||
|
thisId: demoEvents['group'].id,
|
||||||
|
eventIndex: 1,
|
||||||
|
isPast: true,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: 'group',
|
||||||
|
groupColour: 'red',
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['11'])).toMatchObject({
|
||||||
|
previousEvent: demoEvents['1'],
|
||||||
|
latestEvent: demoEvents['11'],
|
||||||
|
previousEntryId: demoEvents['group'].id,
|
||||||
|
thisId: demoEvents['11'].id,
|
||||||
|
eventIndex: 2,
|
||||||
|
isPast: true,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 10,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: 'group',
|
||||||
|
groupColour: 'red',
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['delay'])).toMatchObject({
|
||||||
|
previousEvent: demoEvents['11'],
|
||||||
|
latestEvent: demoEvents['11'],
|
||||||
|
previousEntryId: demoEvents['11'].id,
|
||||||
|
thisId: demoEvents['delay'].id,
|
||||||
|
eventIndex: 2,
|
||||||
|
isPast: true,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 10,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: 'group',
|
||||||
|
groupColour: 'red',
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['12'])).toMatchObject({
|
||||||
|
previousEvent: demoEvents['11'],
|
||||||
|
latestEvent: demoEvents['12'],
|
||||||
|
previousEntryId: demoEvents['delay'].id,
|
||||||
|
thisId: demoEvents['12'].id,
|
||||||
|
eventIndex: 3,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 10,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: true,
|
||||||
|
groupId: 'group',
|
||||||
|
groupColour: 'red',
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['13'])).toMatchObject({
|
||||||
|
previousEvent: demoEvents['12'],
|
||||||
|
latestEvent: demoEvents['13'],
|
||||||
|
previousEntryId: demoEvents['12'].id,
|
||||||
|
thisId: demoEvents['13'].id,
|
||||||
|
eventIndex: 4,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 10,
|
||||||
|
isLinkedToLoaded: true,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: 'group',
|
||||||
|
groupColour: 'red',
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(demoEvents['2'])).toMatchObject({
|
||||||
|
previousEvent: demoEvents['13'],
|
||||||
|
latestEvent: demoEvents['2'],
|
||||||
|
previousEntryId: demoEvents['13'].id,
|
||||||
|
thisId: demoEvents['2'].id,
|
||||||
|
eventIndex: 5,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 17,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
|
isFirstAfterGroup: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates previousEntries in groups', () => {
|
||||||
|
const rundownStartsWithGroup = {
|
||||||
|
group: {
|
||||||
|
id: 'group',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
colour: 'red',
|
||||||
|
entries: ['1', '2'],
|
||||||
|
} as OntimeGroup,
|
||||||
|
'1': {
|
||||||
|
id: '1',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 1,
|
||||||
|
timeEnd: 2,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
'2': {
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 2,
|
||||||
|
timeEnd: 3,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent,
|
||||||
|
};
|
||||||
|
const { process } = initRundownMetadata(null);
|
||||||
|
|
||||||
|
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: null,
|
||||||
|
previousEntryId: null,
|
||||||
|
thisId: rundownStartsWithGroup.group.id,
|
||||||
|
eventIndex: 0,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: rundownStartsWithGroup.group.id,
|
||||||
|
groupColour: 'red',
|
||||||
|
groupEntries: 2,
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: rundownStartsWithGroup['1'],
|
||||||
|
previousEntryId: rundownStartsWithGroup.group.id,
|
||||||
|
thisId: rundownStartsWithGroup['1'].id,
|
||||||
|
eventIndex: 1,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: rundownStartsWithGroup.group.id,
|
||||||
|
groupColour: 'red',
|
||||||
|
groupEntries: 2,
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
|
||||||
|
previousEvent: rundownStartsWithGroup['1'],
|
||||||
|
latestEvent: rundownStartsWithGroup['2'],
|
||||||
|
previousEntryId: rundownStartsWithGroup['1'].id,
|
||||||
|
thisId: rundownStartsWithGroup['2'].id,
|
||||||
|
eventIndex: 2,
|
||||||
|
isPast: false,
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: rundownStartsWithGroup.group.id,
|
||||||
|
groupColour: 'red',
|
||||||
|
groupEntries: 2,
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import {
|
||||||
|
isOntimeEvent,
|
||||||
|
isOntimeGroup,
|
||||||
|
isPlayableEvent,
|
||||||
|
MaybeString,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeEntry,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeMilestone,
|
||||||
|
PlayableEvent,
|
||||||
|
Rundown,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
||||||
|
|
||||||
|
export type RundownMetadata = {
|
||||||
|
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
|
||||||
|
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
||||||
|
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
|
||||||
|
thisId: MaybeString;
|
||||||
|
eventIndex: number;
|
||||||
|
isPast: boolean;
|
||||||
|
isNextDay: boolean;
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
||||||
|
isLoaded: boolean;
|
||||||
|
groupId: MaybeString;
|
||||||
|
groupColour: string | undefined;
|
||||||
|
groupEntries: number | undefined;
|
||||||
|
isFirstAfterGroup: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
||||||
|
|
||||||
|
export const lastMetadataKey = 'LAST';
|
||||||
|
|
||||||
|
export type RundownMetadataObject = Record<string, Readonly<RundownMetadata>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a Rundown Metadata object from a rundown
|
||||||
|
*/
|
||||||
|
export function getRundownMetadata(
|
||||||
|
data: Pick<Rundown, 'entries' | 'flatOrder'>,
|
||||||
|
selectedEventId: MaybeString,
|
||||||
|
): RundownMetadataObject {
|
||||||
|
const { metadata, process } = initRundownMetadata(selectedEventId);
|
||||||
|
// keep a single reference to the metadata which we override for every entry
|
||||||
|
let lastSnapshot = metadata;
|
||||||
|
const rundownMetadata: RundownMetadataObject = {};
|
||||||
|
|
||||||
|
for (const id of data.flatOrder) {
|
||||||
|
const entry = data.entries[id];
|
||||||
|
lastSnapshot = process(entry);
|
||||||
|
rundownMetadata[id] = lastSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure some blank data even for empty rundowns
|
||||||
|
rundownMetadata[lastMetadataKey] = lastSnapshot;
|
||||||
|
|
||||||
|
return rundownMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlatRundownMetadata(
|
||||||
|
data: Pick<Rundown, 'entries' | 'flatOrder'>,
|
||||||
|
selectedEventId: MaybeString,
|
||||||
|
): ExtendedEntry[] {
|
||||||
|
const { process } = initRundownMetadata(selectedEventId);
|
||||||
|
const flatRundown: ExtendedEntry[] = [];
|
||||||
|
|
||||||
|
for (const id of data.flatOrder) {
|
||||||
|
const entry = data.entries[id];
|
||||||
|
const extendedEntry = { ...entry, ...process(entry) };
|
||||||
|
flatRundown.push(extendedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flatRundown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a process function which aggregates the rundown metadata and event metadata
|
||||||
|
*/
|
||||||
|
export function initRundownMetadata(selectedEventId: MaybeString) {
|
||||||
|
let rundownMeta: RundownMetadata = {
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: null,
|
||||||
|
previousEntryId: null,
|
||||||
|
thisId: null,
|
||||||
|
eventIndex: 0,
|
||||||
|
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
||||||
|
isNextDay: false,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
isLoaded: false,
|
||||||
|
groupId: null,
|
||||||
|
groupColour: undefined,
|
||||||
|
groupEntries: undefined,
|
||||||
|
isFirstAfterGroup: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||||
|
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
|
||||||
|
rundownMeta = processedRundownMetadata;
|
||||||
|
return rundownMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metadata: rundownMeta, process };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives a rundown entry and processes its place in the rundown
|
||||||
|
*/
|
||||||
|
function processEntry(
|
||||||
|
rundownMetadata: RundownMetadata,
|
||||||
|
selectedEventId: MaybeString,
|
||||||
|
entry: Readonly<OntimeEntry>,
|
||||||
|
): Readonly<RundownMetadata> {
|
||||||
|
const processedData = { ...rundownMetadata };
|
||||||
|
// initialise data to be overridden below
|
||||||
|
processedData.isNextDay = false;
|
||||||
|
processedData.isLoaded = false;
|
||||||
|
|
||||||
|
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
||||||
|
processedData.thisId = entry.id; // we reassign thisId
|
||||||
|
processedData.previousEvent = processedData.latestEvent;
|
||||||
|
|
||||||
|
if (entry.id === selectedEventId) {
|
||||||
|
processedData.isLoaded = true;
|
||||||
|
processedData.isPast = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeGroup(entry)) {
|
||||||
|
processedData.groupId = entry.id;
|
||||||
|
processedData.groupColour = entry.colour;
|
||||||
|
processedData.groupEntries = entry.entries.length;
|
||||||
|
} else {
|
||||||
|
// for delays and groups, we insert the group metadata
|
||||||
|
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
||||||
|
// if the parent is not the current group, we need to update the groupId
|
||||||
|
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
|
||||||
|
processedData.groupEntries = undefined;
|
||||||
|
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) {
|
||||||
|
// if the entry has no parent, it cannot have a group colour
|
||||||
|
processedData.groupColour = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
|
// event indexes are 1 based in UI
|
||||||
|
processedData.eventIndex += 1;
|
||||||
|
processedData.isFirstAfterGroup = Boolean(processedData.previousEvent?.parent) && entry.parent === null;
|
||||||
|
|
||||||
|
if (isPlayableEvent(entry)) {
|
||||||
|
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||||
|
processedData.totalGap += entry.gap;
|
||||||
|
|
||||||
|
if (!processedData.isPast && !processedData.isLoaded) {
|
||||||
|
/**
|
||||||
|
* isLinkToLoaded is a chain value that we maintain until we
|
||||||
|
* a) find an unlinked event
|
||||||
|
* b) find a countToEnd event
|
||||||
|
*/
|
||||||
|
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||||
|
// this event is the forward most event in rundown, for next iteration
|
||||||
|
processedData.latestEvent = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedData;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
REPORT,
|
REPORT,
|
||||||
RUNDOWN,
|
RUNDOWN,
|
||||||
RUNTIME,
|
RUNTIME,
|
||||||
|
TRANSLATION,
|
||||||
URL_PRESETS,
|
URL_PRESETS,
|
||||||
VIEW_SETTINGS,
|
VIEW_SETTINGS,
|
||||||
} from '../api/constants';
|
} from '../api/constants';
|
||||||
@@ -173,6 +174,9 @@ export const connectSocket = () => {
|
|||||||
case RefetchKey.ViewSettings:
|
case RefetchKey.ViewSettings:
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
||||||
break;
|
break;
|
||||||
|
case RefetchKey.Translation:
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
target satisfies never;
|
target satisfies never;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ interface PanelContentProps {
|
|||||||
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
|
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
|
||||||
return (
|
return (
|
||||||
<div className={style.contentWrapper}>
|
<div className={style.contentWrapper}>
|
||||||
|
<div className={style.content}>{children}</div>
|
||||||
<div className={style.corner}>
|
<div className={style.corner}>
|
||||||
<Button size='large' onClick={onClose}>
|
<Button size='large' onClick={onClose}>
|
||||||
Close settings <IoClose />
|
Close settings <IoClose />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.content}>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ $inner-padding: 1rem;
|
|||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) {
|
tr:nth-child(even) {
|
||||||
|
|||||||
@@ -6,14 +6,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldForm {
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: $gray-1350;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.twoCols {
|
.twoCols {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
type NewRundownFormState = {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ManageRundownForm {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManageRundownForm({ onClose }: ManageRundownForm) {
|
||||||
|
const { create } = useMutateProjectRundowns();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
setFocus,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<NewRundownFormState>({
|
||||||
|
defaultValues: { title: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRundown = async (values: NewRundownFormState) => {
|
||||||
|
try {
|
||||||
|
await create(values.title || 'untitled');
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { message: `Failed to create rundown. ${error}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// give initial focus to the title field
|
||||||
|
useEffect(() => {
|
||||||
|
setFocus('title');
|
||||||
|
}, [setFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
|
||||||
|
<Panel.Section>
|
||||||
|
<label>
|
||||||
|
<Panel.Description>Rundown title</Panel.Description>
|
||||||
|
<Input {...register('title')} fluid placeholder='Your rundown name' />
|
||||||
|
</label>
|
||||||
|
</Panel.Section>
|
||||||
|
<Panel.InlineElements relation='inner' align='end'>
|
||||||
|
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' variant='primary' disabled={isSubmitting}>
|
||||||
|
Create rundown
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
</Panel.Indent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,59 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
import { IoAdd } from 'react-icons/io5';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import { ManageRundownForm } from './ManageRundownForm';
|
||||||
|
|
||||||
import style from './ManagePanel.module.scss';
|
import style from './ManagePanel.module.scss';
|
||||||
|
|
||||||
export default function ManageRundowns() {
|
export default function ManageRundowns() {
|
||||||
const { data } = useProjectRundowns();
|
const { data } = useProjectRundowns();
|
||||||
const [deleteOpen, deleteHandlers] = useDisclosure();
|
const { remove, load } = useMutateProjectRundowns();
|
||||||
const [loadOpen, loadHandlers] = useDisclosure();
|
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||||
|
const [isOpenLoad, loadHandlers] = useDisclosure();
|
||||||
|
const [isNewLoad, newHandlers] = useDisclosure();
|
||||||
|
const [targetRundown, setTargetRundown] = useState('');
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openLoad = (id: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
setTargetRundown(id);
|
||||||
|
loadHandlers.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDelete = (id: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
setTargetRundown(id);
|
||||||
|
deleteHandlers.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRundownLoad = async () => {
|
||||||
|
try {
|
||||||
|
await load(targetRundown);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(`Failed to load rundown. ${maybeAxiosError(error)}`);
|
||||||
|
} finally {
|
||||||
|
loadHandlers.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRundownDelete = async () => {
|
||||||
|
try {
|
||||||
|
await remove(targetRundown);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(`Failed to delete rundown. ${maybeAxiosError(error)}`);
|
||||||
|
} finally {
|
||||||
|
deleteHandlers.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -21,49 +62,60 @@ export default function ManageRundowns() {
|
|||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Manage project rundowns
|
Manage project rundowns
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Button onClick={() => undefined} disabled>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
newHandlers.open();
|
||||||
|
}}
|
||||||
|
>
|
||||||
New <IoAdd />
|
New <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Table>
|
<Panel.Section>
|
||||||
<thead>
|
{isNewLoad && <ManageRundownForm onClose={newHandlers.close} />}
|
||||||
<tr>
|
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||||
<th># Entries</th>
|
<Panel.Table>
|
||||||
<th style={{ width: '100%' }}>Title</th>
|
<thead>
|
||||||
<th />
|
<tr>
|
||||||
</tr>
|
<th># Entries</th>
|
||||||
</thead>
|
<th style={{ width: '100%' }}>Title</th>
|
||||||
<tbody>
|
<th />
|
||||||
{data.rundowns.map((rundown) => {
|
</tr>
|
||||||
const isLoaded = data.loaded === rundown.id;
|
</thead>
|
||||||
return (
|
<tbody>
|
||||||
<tr key={rundown.id} className={cx([isLoaded && style.current])}>
|
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
||||||
<td>{rundown.numEntries}</td>
|
const isLoaded = data.loaded === id;
|
||||||
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td>
|
return (
|
||||||
<Panel.InlineElements as='td'>
|
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||||
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}>
|
<td>{numEntries}</td>
|
||||||
Load
|
<td>
|
||||||
</Button>
|
{title} {isLoaded && <Tag>Loaded</Tag>}
|
||||||
<Button
|
</td>
|
||||||
size='small'
|
<Panel.InlineElements as='td'>
|
||||||
variant='subtle-destructive'
|
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
|
||||||
onClick={() => deleteHandlers.open()}
|
Load
|
||||||
disabled={isLoaded}
|
</Button>
|
||||||
>
|
<Button
|
||||||
Delete
|
size='small'
|
||||||
</Button>
|
variant='subtle-destructive'
|
||||||
</Panel.InlineElements>
|
onClick={() => openDelete(id)}
|
||||||
</tr>
|
disabled={isLoaded}
|
||||||
);
|
>
|
||||||
})}
|
Delete
|
||||||
</tbody>
|
</Button>
|
||||||
</Panel.Table>
|
</Panel.InlineElements>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={deleteOpen}
|
isOpen={isOpenDelete}
|
||||||
onClose={deleteHandlers.close}
|
onClose={deleteHandlers.close}
|
||||||
title='Load rundown'
|
title='Load rundown'
|
||||||
showBackdrop
|
showBackdrop
|
||||||
@@ -78,16 +130,16 @@ export default function ManageRundowns() {
|
|||||||
<Button size='large' onClick={deleteHandlers.close}>
|
<Button size='large' onClick={deleteHandlers.close}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='destructive' size='large' onClick={() => undefined}>
|
<Button variant='destructive' size='large' onClick={submitRundownDelete}>
|
||||||
Delete rundown
|
Delete rundown
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={loadOpen}
|
isOpen={isOpenLoad}
|
||||||
onClose={loadHandlers.close}
|
onClose={loadHandlers.close}
|
||||||
title='Delete rundown'
|
title='Load rundown'
|
||||||
showBackdrop
|
showBackdrop
|
||||||
showCloseButton
|
showCloseButton
|
||||||
bodyElements={
|
bodyElements={
|
||||||
@@ -100,7 +152,7 @@ export default function ManageRundowns() {
|
|||||||
<Button size='large' onClick={loadHandlers.close}>
|
<Button size='large' onClick={loadHandlers.close}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='primary' size='large' onClick={() => undefined}>
|
<Button variant='primary' size='large' onClick={submitRundownLoad}>
|
||||||
Load rundown
|
Load rundown
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+11
-14
@@ -83,11 +83,7 @@ export default function CustomFieldForm({
|
|||||||
const isEditMode = initialKey !== undefined;
|
const isEditMode = initialKey !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
||||||
onSubmit={handleSubmit(setupSubmit)}
|
|
||||||
className={style.fieldForm}
|
|
||||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
|
||||||
>
|
|
||||||
<Info>
|
<Info>
|
||||||
Please note that images can quickly deteriorate your app's performance.
|
Please note that images can quickly deteriorate your app's performance.
|
||||||
<br />
|
<br />
|
||||||
@@ -107,7 +103,7 @@ export default function CustomFieldForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.twoCols}>
|
<div className={style.twoCols}>
|
||||||
<div>
|
<label>
|
||||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||||
<Input
|
<Input
|
||||||
@@ -116,7 +112,8 @@ export default function CustomFieldForm({
|
|||||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
if (value.trim().length === 0) return 'Required field';
|
if (value.trim().length === 0) return 'Required field';
|
||||||
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||||
|
return 'Only alphanumeric characters and space are allowed';
|
||||||
if (!isEditMode) {
|
if (!isEditMode) {
|
||||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||||
}
|
}
|
||||||
@@ -125,17 +122,17 @@ export default function CustomFieldForm({
|
|||||||
})}
|
})}
|
||||||
fluid
|
fluid
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div>
|
<label>
|
||||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||||
<Input {...register('key')} readOnly fluid />
|
<Input {...register('key')} variant='ghosted' readOnly fluid />
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<label>
|
||||||
<Panel.Description>Colour</Panel.Description>
|
<Panel.Description>Colour</Panel.Description>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
</div>
|
</label>
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<Panel.InlineElements relation='inner' align='end'>
|
<Panel.InlineElements relation='inner' align='end'>
|
||||||
<Button variant='ghosted' onClick={onCancel}>
|
<Button variant='ghosted' onClick={onCancel}>
|
||||||
@@ -145,6 +142,6 @@ export default function CustomFieldForm({
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</form>
|
</Panel.Indent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -12,6 +12,7 @@ tr .secondaryRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.linkStartActive {
|
.linkStartActive {
|
||||||
|
flex-shrink: 0;
|
||||||
color: $active-indicator;
|
color: $active-indicator;
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-12
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { IoLink } from 'react-icons/io5';
|
import { IoLink } from 'react-icons/io5';
|
||||||
import { CustomFields, isOntimeEvent, isOntimeGroup, Rundown } from 'ontime-types';
|
import { CustomFields, isOntimeEvent, isOntimeGroup, isOntimeMilestone, Rundown } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import Tag from '../../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../../common/components/tag/Tag';
|
||||||
@@ -53,9 +53,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rundown.order.map((entryId) => {
|
{rundown.flatOrder.map((entryId) => {
|
||||||
const entry = rundown.entries[entryId];
|
const entry = rundown.entries[entryId];
|
||||||
if (isOntimeGroup(entry)) {
|
if (isOntimeGroup(entry)) {
|
||||||
|
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
<td className={style.center}>
|
<td className={style.center}>
|
||||||
@@ -64,11 +65,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<td className={style.center}>
|
<td className={style.center}>
|
||||||
<Tag>{entry.type}</Tag>
|
<Tag>{entry.type}</Tag>
|
||||||
</td>
|
</td>
|
||||||
<td />
|
<td /> {/** CUE */}
|
||||||
<td colSpan={99}>{entry.title}</td>
|
<td>{entry.title}</td>
|
||||||
|
<td /> {/** Flag */}
|
||||||
|
<td /> {/** Time Start */}
|
||||||
|
<td /> {/** Time End */}
|
||||||
|
<td /> {/** Duration */}
|
||||||
|
<td /> {/** Warning Time */}
|
||||||
|
<td /> {/** Danger Time */}
|
||||||
|
<td /> {/** Count to end */}
|
||||||
|
<td /> {/** Skip */}
|
||||||
|
<td style={{ ...colour }}>{entry.colour}</td>
|
||||||
|
<td /> {/** Timer Type */}
|
||||||
|
<td /> {/** End Action */}
|
||||||
|
{fieldKeys.map((field) => {
|
||||||
|
let value = '';
|
||||||
|
if (field in entry.custom) {
|
||||||
|
value = entry.custom[field];
|
||||||
|
}
|
||||||
|
return <td key={field}>{value}</td>;
|
||||||
|
})}
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{entry.id}</Tag>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isOntimeMilestone(entry)) {
|
||||||
|
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||||
|
return (
|
||||||
|
<Fragment key={entry.id}>
|
||||||
|
<tr>
|
||||||
|
<td /> {/** Index */}
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{entry.type}</Tag>
|
||||||
|
</td>
|
||||||
|
<td className={style.nowrap}>{entry.cue}</td>
|
||||||
|
<td>{entry.title}</td>
|
||||||
|
<td /> {/** Flag */}
|
||||||
|
<td /> {/** Time Start */}
|
||||||
|
<td /> {/** Time End */}
|
||||||
|
<td /> {/** Duration */}
|
||||||
|
<td /> {/** Warning Time */}
|
||||||
|
<td /> {/** Danger Time */}
|
||||||
|
<td /> {/** Count to end */}
|
||||||
|
<td /> {/** Skip */}
|
||||||
|
<td style={{ ...colour }}>{entry.colour}</td>
|
||||||
|
<td /> {/** Timer Type */}
|
||||||
|
<td /> {/** End Action */}
|
||||||
|
{fieldKeys.map((field) => {
|
||||||
|
let value = '';
|
||||||
|
if (field in entry.custom) {
|
||||||
|
value = entry.custom[field];
|
||||||
|
}
|
||||||
|
return <td key={field}>{value}</td>;
|
||||||
|
})}
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{entry.id}</Tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{entry.note && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={99} className={style.secondaryRow}>
|
||||||
|
Note: {entry.note}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!isOntimeEvent(entry)) {
|
if (!isOntimeEvent(entry)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -107,14 +172,13 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<td className={style.center}>
|
<td className={style.center}>
|
||||||
<Tag>{entry.endAction}</Tag>
|
<Tag>{entry.endAction}</Tag>
|
||||||
</td>
|
</td>
|
||||||
{isOntimeEvent(entry) &&
|
{fieldKeys.map((field) => {
|
||||||
fieldKeys.map((field) => {
|
let value = '';
|
||||||
let value = '';
|
if (field in entry.custom) {
|
||||||
if (field in entry.custom) {
|
value = entry.custom[field];
|
||||||
value = entry.custom[field];
|
}
|
||||||
}
|
return <td key={field}>{value}</td>;
|
||||||
return <td key={field}>{value}</td>;
|
})}
|
||||||
})}
|
|
||||||
<td className={style.center}>
|
<td className={style.center}>
|
||||||
<Tag>{entry.id}</Tag>
|
<Tag>{entry.id}</Tag>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section
|
<Panel.Indent
|
||||||
as='form'
|
as='form'
|
||||||
onSubmit={handleSubmit(handleSubmitCreate)}
|
onSubmit={handleSubmit(handleSubmitCreate)}
|
||||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||||
@@ -76,11 +76,9 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
|||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
<Panel.Section className={style.innerColumn}>
|
<Panel.Section className={style.innerColumn}>
|
||||||
<label>
|
<Panel.Description>Project title</Panel.Description>
|
||||||
Project title
|
<Input fluid placeholder='Your project name' {...register('title')} />
|
||||||
<Input fluid placeholder='Your project name' {...register('title')} />
|
|
||||||
</label>
|
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</Panel.Section>
|
</Panel.Indent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { lazy, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { Settings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { postSettings } from '../../../../common/api/settings';
|
import { postSettings } from '../../../../common/api/settings';
|
||||||
@@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
|||||||
|
|
||||||
import GeneralPinInput from './composite/GeneralPinInput';
|
import GeneralPinInput from './composite/GeneralPinInput';
|
||||||
|
|
||||||
|
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
|
||||||
|
|
||||||
export default function GeneralSettings() {
|
export default function GeneralSettings() {
|
||||||
const { data, status, refetch } = useSettings();
|
const { data, status, refetch } = useSettings();
|
||||||
const {
|
const {
|
||||||
@@ -34,6 +37,8 @@ export default function GeneralSettings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isOpen, handler] = useDisclosure();
|
||||||
|
|
||||||
// update form if we get new data from server
|
// update form if we get new data from server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -63,112 +68,124 @@ export default function GeneralSettings() {
|
|||||||
const isLoading = status === 'pending';
|
const isLoading = status === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section
|
<>
|
||||||
as='form'
|
<TranslationModal isOpen={isOpen} onClose={handler.close} />
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
<Panel.Section
|
||||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
as='form'
|
||||||
id='app-settings'
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
>
|
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||||
<Panel.Card>
|
id='app-settings'
|
||||||
<Panel.SubHeader>
|
>
|
||||||
General settings
|
<Panel.Card>
|
||||||
<Panel.InlineElements>
|
<Panel.SubHeader>
|
||||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
General settings
|
||||||
Revert to saved
|
<Panel.InlineElements>
|
||||||
</Button>
|
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||||
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'>
|
Revert to saved
|
||||||
Save
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</Panel.InlineElements>
|
type='submit'
|
||||||
</Panel.SubHeader>
|
form='app-settings'
|
||||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
name='general-settings-submit'
|
||||||
<Panel.Divider />
|
loading={isSubmitting}
|
||||||
<Panel.Section>
|
disabled={disableSubmit}
|
||||||
<Panel.Loader isLoading={isLoading} />
|
variant='primary'
|
||||||
<Panel.ListGroup>
|
>
|
||||||
<Panel.ListItem>
|
Save
|
||||||
<Panel.Field
|
</Button>
|
||||||
title='Ontime server port'
|
</Panel.InlineElements>
|
||||||
description={
|
</Panel.SubHeader>
|
||||||
isOntimeCloud
|
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||||
? 'Server port disabled for Ontime Cloud'
|
<Panel.Divider />
|
||||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
<Panel.Section>
|
||||||
}
|
<Panel.Loader isLoading={isLoading} />
|
||||||
error={errors.serverPort?.message}
|
<Panel.ListGroup>
|
||||||
/>
|
<Panel.ListItem>
|
||||||
<Input
|
<Panel.Field
|
||||||
id='serverPort'
|
title='Ontime server port'
|
||||||
type='number'
|
description={
|
||||||
maxLength={5}
|
isOntimeCloud
|
||||||
style={{ width: '75px' }}
|
? 'Server port disabled for Ontime Cloud'
|
||||||
disabled={isOntimeCloud}
|
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||||
{...register('serverPort', {
|
}
|
||||||
required: { value: true, message: 'Required field' },
|
error={errors.serverPort?.message}
|
||||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
/>
|
||||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
<Input
|
||||||
pattern: {
|
id='serverPort'
|
||||||
value: isOnlyNumbers,
|
type='number'
|
||||||
message: 'Value should be numeric',
|
maxLength={5}
|
||||||
},
|
style={{ width: '75px' }}
|
||||||
})}
|
disabled={isOntimeCloud}
|
||||||
/>
|
{...register('serverPort', {
|
||||||
</Panel.ListItem>
|
required: { value: true, message: 'Required field' },
|
||||||
<Panel.ListItem>
|
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||||
<Panel.Field
|
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||||
title='Editor pin code'
|
pattern: {
|
||||||
description='Protect the editor view with a pin code'
|
value: isOnlyNumbers,
|
||||||
error={errors.editorKey?.message}
|
message: 'Value should be numeric',
|
||||||
/>
|
},
|
||||||
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
|
})}
|
||||||
</Panel.ListItem>
|
/>
|
||||||
<Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.ListItem>
|
||||||
title='Operator pin code'
|
<Panel.Field
|
||||||
description='Protect the operator and cuesheet views with a pin code'
|
title='Editor pin code'
|
||||||
error={errors.operatorKey?.message}
|
description='Protect the editor view with a pin code'
|
||||||
/>
|
error={errors.editorKey?.message}
|
||||||
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
|
/>
|
||||||
</Panel.ListItem>
|
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
|
||||||
<Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.ListItem>
|
||||||
title='Time format'
|
<Panel.Field
|
||||||
description='Default time format to show in views 12 /24 hours'
|
title='Operator pin code'
|
||||||
error={errors.timeFormat?.message}
|
description='Protect the operator and cuesheet views with a pin code'
|
||||||
/>
|
error={errors.operatorKey?.message}
|
||||||
<Select
|
/>
|
||||||
value={watch('timeFormat')}
|
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
|
||||||
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
</Panel.ListItem>
|
||||||
defaultValue='24'
|
<Panel.ListItem>
|
||||||
options={[
|
<Panel.Field
|
||||||
{ value: '12', label: '12 hours 11:00:10 PM' },
|
title='Time format'
|
||||||
{ value: '24', label: '24 hours 23:00:10' },
|
description='Default time format to show in views 12 /24 hours'
|
||||||
]}
|
error={errors.timeFormat?.message}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
<Select
|
||||||
<Panel.ListItem>
|
value={watch('timeFormat')}
|
||||||
<Panel.Field
|
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
||||||
title='Views language'
|
defaultValue='24'
|
||||||
description='Language to be displayed in views'
|
options={[
|
||||||
error={errors.language?.message}
|
{ value: '12', label: '12 hours 11:00:10 PM' },
|
||||||
/>
|
{ value: '24', label: '24 hours 23:00:10' },
|
||||||
<Select
|
]}
|
||||||
value={watch('language')}
|
/>
|
||||||
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
</Panel.ListItem>
|
||||||
disabled={disableInputs}
|
<Panel.ListItem>
|
||||||
defaultValue='en'
|
<Panel.Field
|
||||||
options={[
|
title='Views language'
|
||||||
{ value: 'en', label: 'English' },
|
description='Language to be displayed in views'
|
||||||
{ value: 'fr', label: 'French' },
|
error={errors.language?.message}
|
||||||
{ value: 'de', label: 'German' },
|
/>
|
||||||
{ value: 'it', label: 'Italian' },
|
<Select
|
||||||
{ value: 'pt', label: 'Portuguese' },
|
value={watch('language')}
|
||||||
{ value: 'es', label: 'Spanish' },
|
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
||||||
]}
|
disabled={disableInputs}
|
||||||
/>
|
defaultValue='en'
|
||||||
</Panel.ListItem>
|
options={[
|
||||||
</Panel.ListGroup>
|
{ value: 'en', label: 'English' },
|
||||||
</Panel.Section>
|
{ value: 'fr', label: 'French' },
|
||||||
</Panel.Card>
|
{ value: 'de', label: 'German' },
|
||||||
</Panel.Section>
|
{ value: 'it', label: 'Italian' },
|
||||||
|
{ value: 'pt', label: 'Portuguese' },
|
||||||
|
{ value: 'es', label: 'Spanish' },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button onClick={handler.open}>Edit custom translation</Button>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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('_', '.');
|
||||||
|
}
|
||||||
@@ -37,13 +37,14 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
|
|||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||||
import { cloneEvent } from '../../common/utils/clone';
|
import { cloneEvent } from '../../common/utils/clone';
|
||||||
|
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
|
|
||||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||||
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
||||||
import RundownGroup from './rundown-group/RundownGroup';
|
import RundownGroup from './rundown-group/RundownGroup';
|
||||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||||
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils';
|
import { canDrop, makeSortableList } from './rundown.utils';
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
@@ -53,13 +54,15 @@ const RundownEntry = lazy(() => import('./RundownEntry'));
|
|||||||
|
|
||||||
interface RundownProps {
|
interface RundownProps {
|
||||||
data: Rundown;
|
data: Rundown;
|
||||||
|
rundownMetadata: RundownMetadataObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Rundown({ data }: RundownProps) {
|
export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||||
const { order, entries, id } = data;
|
const { order, entries, id } = data;
|
||||||
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
||||||
const featureData = useRundownEditor();
|
const featureData = useRundownEditor();
|
||||||
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
||||||
|
const [metadata, setMetadata] = useState(rundownMetadata);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||||
// we ensure that this is unique to the rundown
|
// we ensure that this is unique to the rundown
|
||||||
key: `rundown.${id}-editor-collapsed-groups`,
|
key: `rundown.${id}-editor-collapsed-groups`,
|
||||||
@@ -306,7 +309,8 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
// to workaround async updates on the drag mutations
|
// to workaround async updates on the drag mutations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSortableData(makeSortableList(order, entries));
|
setSortableData(makeSortableList(order, entries));
|
||||||
}, [order, entries]);
|
setMetadata(rundownMetadata);
|
||||||
|
}, [order, entries, rundownMetadata]);
|
||||||
|
|
||||||
// in run mode, we follow selection
|
// in run mode, we follow selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -334,19 +338,24 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevent dropping a group inside another
|
if (!active.data.current || !over.data.current) {
|
||||||
if (
|
|
||||||
active.data.current?.type === SupportedEntry.Group &&
|
|
||||||
!canDrop(over.data.current?.type, over.data.current?.parent)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromIndex = active.data.current?.sortable.index;
|
const fromIndex: number = active.data.current.sortable.index;
|
||||||
const toIndex = over.data.current?.sortable.index;
|
const toIndex: number = over.data.current.sortable.index;
|
||||||
|
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||||
|
|
||||||
let destinationId = over.id as EntryId;
|
let destinationId = over.id as EntryId;
|
||||||
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
|
||||||
|
|
||||||
|
// prevent dropping a group inside another
|
||||||
|
if (
|
||||||
|
isDraggingGroup &&
|
||||||
|
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We need to specially handle the end-group
|
* We need to specially handle the end-group
|
||||||
@@ -357,16 +366,25 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
if (destinationId.startsWith('end-')) {
|
if (destinationId.startsWith('end-')) {
|
||||||
destinationId = destinationId.replace('end-', '');
|
destinationId = destinationId.replace('end-', '');
|
||||||
// if we are moving before the end, we use the insert operation
|
// if we are moving before the end, we use the insert operation
|
||||||
if (order === 'before') {
|
if (placement === 'before') {
|
||||||
order = 'insert';
|
placement = 'insert';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const group = data.entries[destinationId];
|
const group = data.entries[destinationId];
|
||||||
if (isOntimeGroup(group) && order === 'after') {
|
// if dragging into a group
|
||||||
if (group.entries.length === 0) order = 'insert';
|
if (isOntimeGroup(group) && placement === 'after') {
|
||||||
else {
|
if (isDraggingGroup) {
|
||||||
|
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
|
||||||
|
// so we can safely push the dragged event after the group
|
||||||
|
destinationId = group.id;
|
||||||
|
} else if (group.entries.length === 0) {
|
||||||
|
// ... and the group is entry, we insert
|
||||||
|
destinationId = group.id;
|
||||||
|
placement = 'insert';
|
||||||
|
} else {
|
||||||
|
// otherwise we add it to before the first group child
|
||||||
destinationId = group.entries[0];
|
destinationId = group.entries[0];
|
||||||
order = 'before';
|
placement = 'before';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +395,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
setSortableData((currentEntries) => {
|
setSortableData((currentEntries) => {
|
||||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||||
});
|
});
|
||||||
reorderEntry(active.id as EntryId, destinationId, order).catch((_) => {
|
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
|
||||||
setSortableData(currentEntries);
|
setSortableData(currentEntries);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -418,11 +436,6 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
// 1. gather presentation options
|
// 1. gather presentation options
|
||||||
const isEditMode = editorMode === AppMode.Edit;
|
const isEditMode = editorMode === AppMode.Edit;
|
||||||
|
|
||||||
// 2. initialise rundown metadata
|
|
||||||
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
|
|
||||||
// keep a single reference to the metadata which we override for every entry
|
|
||||||
let rundownMetadata = metadata;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -440,6 +453,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
if (entryId.startsWith('end-')) {
|
if (entryId.startsWith('end-')) {
|
||||||
const parentId = entryId.split('end-')[1];
|
const parentId = entryId.split('end-')[1];
|
||||||
const isGroupCollapsed = getIsCollapsed(parentId);
|
const isGroupCollapsed = getIsCollapsed(parentId);
|
||||||
|
const parentMetadata = metadata[parentId];
|
||||||
|
|
||||||
if (isGroupCollapsed) {
|
if (isGroupCollapsed) {
|
||||||
return null;
|
return null;
|
||||||
@@ -450,14 +464,14 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||||
return (
|
return (
|
||||||
<Fragment key={entryId}>
|
<Fragment key={entryId}>
|
||||||
{isEditMode && rundownMetadata.groupEntries === 0 && (
|
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
||||||
<QuickAddButtons
|
<QuickAddButtons
|
||||||
previousEventId={null}
|
previousEventId={null}
|
||||||
parentGroup={parentId}
|
parentGroup={parentId}
|
||||||
backgroundColor={rundownMetadata.groupColour}
|
backgroundColor={parentMetadata?.groupColour}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RundownGroupEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} />
|
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -466,15 +480,14 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
// this means that this can be out of sync with order until the useEffect runs
|
// this means that this can be out of sync with order until the useEffect runs
|
||||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
// instead of writing all the logic guards, we simply short circuit rendering here
|
||||||
const entry = entries[entryId];
|
const entry = entries[entryId];
|
||||||
if (!entry) return null;
|
const entryMetadata = metadata[entryId];
|
||||||
|
if (!entry || !entryMetadata) return null;
|
||||||
rundownMetadata = process(entry);
|
|
||||||
|
|
||||||
// if the entry has a parent, and it is collapsed, render nothing
|
// if the entry has a parent, and it is collapsed, render nothing
|
||||||
if (
|
if (
|
||||||
entry.type !== SupportedEntry.Group &&
|
entry.type !== SupportedEntry.Group &&
|
||||||
rundownMetadata.groupId !== null &&
|
entryMetadata.groupId !== null &&
|
||||||
getIsCollapsed(rundownMetadata.groupId)
|
getIsCollapsed(entryMetadata.groupId)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -488,7 +501,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
* ie: we are inside a group, but there is no defined colour
|
* ie: we are inside a group, but there is no defined colour
|
||||||
* we default to $gray-500 #9d9d9d
|
* we default to $gray-500 #9d9d9d
|
||||||
*/
|
*/
|
||||||
const groupColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour;
|
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
||||||
|
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = entryId === order.at(-1);
|
const isLast = entryId === order.at(-1);
|
||||||
@@ -500,9 +513,8 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
* - when adding after, we can use the group ID directly to insert at the top of the group
|
* - when adding after, we can use the group ID directly to insert at the top of the group
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const parentIdForBefore =
|
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
|
||||||
rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null;
|
const parentIdForAfter = entryMetadata.groupId;
|
||||||
const parentIdForAfter = rundownMetadata.groupId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
@@ -513,7 +525,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
* - if it is not the first entry (the buttons would be there)
|
* - if it is not the first entry (the buttons would be there)
|
||||||
*/}
|
*/}
|
||||||
{isEditMode && hasCursor && !isFirst && (
|
{isEditMode && hasCursor && !isFirst && (
|
||||||
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentGroup={parentIdForBefore} />
|
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
||||||
)}
|
)}
|
||||||
{isOntimeGroup(entry) ? (
|
{isOntimeGroup(entry) ? (
|
||||||
<RundownGroup
|
<RundownGroup
|
||||||
@@ -525,31 +537,29 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={style.entryWrapper}
|
className={style.entryWrapper}
|
||||||
data-testid={`entry-${rundownMetadata.eventIndex}`}
|
data-testid={`entry-${entryMetadata.eventIndex}`}
|
||||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||||
>
|
>
|
||||||
{isOntimeEvent(entry) && (
|
{isOntimeEvent(entry) && (
|
||||||
<div className={style.entryIndex}>
|
<div className={style.entryIndex}>
|
||||||
{entry.flag && <TbFlagFilled className={style.flag} />}
|
{entry.flag && <TbFlagFilled className={style.flag} />}
|
||||||
<div className={style.index}>{rundownMetadata.eventIndex}</div>
|
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||||
<RundownEntry
|
<RundownEntry
|
||||||
type={entry.type}
|
type={entry.type}
|
||||||
isPast={rundownMetadata.isPast}
|
isPast={entryMetadata.isPast}
|
||||||
eventIndex={rundownMetadata.eventIndex}
|
eventIndex={entryMetadata.eventIndex}
|
||||||
data={entry}
|
data={entry}
|
||||||
loaded={rundownMetadata.isLoaded}
|
loaded={entryMetadata.isLoaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
previousEntryId={rundownMetadata.previousEntryId}
|
isNextDay={entryMetadata.isNextDay}
|
||||||
previousEventId={rundownMetadata.previousEvent?.id}
|
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
|
||||||
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
|
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
isNextDay={rundownMetadata.isNextDay}
|
totalGap={entryMetadata.totalGap}
|
||||||
totalGap={rundownMetadata.totalGap}
|
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
||||||
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -562,13 +572,16 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
* - if the entry is not the group header
|
* - if the entry is not the group header
|
||||||
*/}
|
*/}
|
||||||
{isEditMode && hasCursor && !isLast && (
|
{isEditMode && hasCursor && !isLast && (
|
||||||
<QuickAddInline previousEventId={entry.id} parentGroup={parentIdForAfter} />
|
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<QuickAddButtons previousEventId={rundownMetadata.groupId ?? rundownMetadata.thisId} parentGroup={null} />
|
<QuickAddButtons
|
||||||
|
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
|
||||||
|
parentGroup={null}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={style.spacer} />
|
<div className={style.spacer} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import {
|
import {
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeMilestone,
|
isOntimeMilestone,
|
||||||
MaybeString,
|
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
@@ -12,26 +10,11 @@ import {
|
|||||||
|
|
||||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||||
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||||
import { useEmitLog } from '../../common/stores/logger';
|
|
||||||
import { cloneEvent } from '../../common/utils/clone';
|
import { cloneEvent } from '../../common/utils/clone';
|
||||||
|
|
||||||
import RundownDelay from './rundown-delay/RundownDelay';
|
import RundownDelay from './rundown-delay/RundownDelay';
|
||||||
import RundownEvent from './rundown-event/RundownEvent';
|
import RundownEvent from './rundown-event/RundownEvent';
|
||||||
import RundownMilestone from './rundown-milestone/RundownMilestone';
|
import RundownMilestone from './rundown-milestone/RundownMilestone';
|
||||||
import { useEventSelection } from './useEventSelection';
|
|
||||||
|
|
||||||
export type EventItemActions =
|
|
||||||
| 'event'
|
|
||||||
| 'event-before'
|
|
||||||
| 'delay'
|
|
||||||
| 'delay-before'
|
|
||||||
| 'group'
|
|
||||||
| 'group-before'
|
|
||||||
| 'swap'
|
|
||||||
| 'delete'
|
|
||||||
| 'clone'
|
|
||||||
| 'make-group'
|
|
||||||
| 'update';
|
|
||||||
|
|
||||||
interface RundownEntryProps {
|
interface RundownEntryProps {
|
||||||
type: SupportedEntry;
|
type: SupportedEntry;
|
||||||
@@ -42,8 +25,6 @@ interface RundownEntryProps {
|
|||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
isNextDay: boolean;
|
isNextDay: boolean;
|
||||||
previousEntryId: MaybeString;
|
|
||||||
previousEventId?: string;
|
|
||||||
playback?: Playback; // we only care about this if this event is playing
|
playback?: Playback; // we only care about this if this event is playing
|
||||||
isRolling: boolean; // we need to know even if not related to this event
|
isRolling: boolean; // we need to know even if not related to this event
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
@@ -56,8 +37,6 @@ export default function RundownEntry({
|
|||||||
loaded,
|
loaded,
|
||||||
hasCursor,
|
hasCursor,
|
||||||
isNext,
|
isNext,
|
||||||
previousEntryId,
|
|
||||||
previousEventId,
|
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
@@ -65,105 +44,11 @@ export default function RundownEntry({
|
|||||||
totalGap,
|
totalGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
}: RundownEntryProps) {
|
}: RundownEntryProps) {
|
||||||
const { emitError } = useEmitLog();
|
const { addEntry } = useEntryActions();
|
||||||
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
|
|
||||||
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
|
|
||||||
|
|
||||||
const removeOpenEvent = useCallback(() => {
|
const createCloneEvent = useMemoisedFn(() => {
|
||||||
unselect(data.id);
|
const newEvent = cloneEvent(data as OntimeEvent);
|
||||||
}, [unselect, data.id]);
|
addEntry(newEvent, { after: data.id });
|
||||||
|
|
||||||
const clearMultiSelection = useCallback(() => {
|
|
||||||
clearSelectedEvents();
|
|
||||||
}, [clearSelectedEvents]);
|
|
||||||
|
|
||||||
// Create / delete new events
|
|
||||||
type FieldValue = {
|
|
||||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
|
||||||
value: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
|
|
||||||
switch (action) {
|
|
||||||
case 'event': {
|
|
||||||
const newEvent = { type: SupportedEntry.Event };
|
|
||||||
const options = {
|
|
||||||
after: data.id,
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
};
|
|
||||||
return addEntry(newEvent, options);
|
|
||||||
}
|
|
||||||
case 'event-before': {
|
|
||||||
const newEvent = { type: SupportedEntry.Event };
|
|
||||||
const options = {
|
|
||||||
after: previousEntryId,
|
|
||||||
};
|
|
||||||
return addEntry(newEvent, options);
|
|
||||||
}
|
|
||||||
case 'delay': {
|
|
||||||
return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
|
|
||||||
}
|
|
||||||
case 'delay-before': {
|
|
||||||
return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
|
|
||||||
}
|
|
||||||
case 'group': {
|
|
||||||
return addEntry({ type: SupportedEntry.Group }, { after: data.id });
|
|
||||||
}
|
|
||||||
case 'group-before': {
|
|
||||||
return addEntry({ type: SupportedEntry.Group }, { after: previousEntryId });
|
|
||||||
}
|
|
||||||
case 'swap': {
|
|
||||||
const { value } = payload as FieldValue;
|
|
||||||
return swapEvents({ from: value as string, to: data.id });
|
|
||||||
}
|
|
||||||
case 'delete': {
|
|
||||||
if (selectedEvents.size > 1) {
|
|
||||||
clearMultiSelection();
|
|
||||||
return deleteEntry(Array.from(selectedEvents));
|
|
||||||
}
|
|
||||||
removeOpenEvent();
|
|
||||||
return deleteEntry([data.id]);
|
|
||||||
}
|
|
||||||
case 'clone': {
|
|
||||||
const newEvent = cloneEvent(data as OntimeEvent);
|
|
||||||
addEntry(newEvent, { after: data.id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'make-group': {
|
|
||||||
if (selectedEvents.size > 1) {
|
|
||||||
clearMultiSelection();
|
|
||||||
return groupEntries(Array.from(selectedEvents));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'update': {
|
|
||||||
// Handles and filters update requests
|
|
||||||
const { field, value } = payload as FieldValue;
|
|
||||||
if (field === undefined || value === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newData: Partial<OntimeEvent> = { id: data.id };
|
|
||||||
|
|
||||||
// if selected events are more than one
|
|
||||||
// we need to bulk edit
|
|
||||||
if (selectedEvents.size > 1) {
|
|
||||||
const changes: Partial<OntimeEvent> = { [field]: value };
|
|
||||||
batchUpdateEvents(changes, Array.from(selectedEvents));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (field in data) {
|
|
||||||
// @ts-expect-error -- not sure how to type this
|
|
||||||
newData[field] = value;
|
|
||||||
return updateEntry(newData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return emitError(`Unknown field: ${field}`);
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
action satisfies never;
|
|
||||||
throw new Error(`Unhandled event ${action}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isOntimeEvent(data)) {
|
if (isOntimeEvent(data)) {
|
||||||
@@ -198,7 +83,7 @@ export default function RundownEntry({
|
|||||||
dayOffset={data.dayOffset}
|
dayOffset={data.dayOffset}
|
||||||
totalGap={totalGap}
|
totalGap={totalGap}
|
||||||
isLinkedToLoaded={isLinkedToLoaded}
|
isLinkedToLoaded={isLinkedToLoaded}
|
||||||
actionHandler={actionHandler}
|
createCloneEvent={createCloneEvent}
|
||||||
hasTriggers={data.triggers.length > 0}
|
hasTriggers={data.triggers.length > 0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||||
|
|
||||||
import RundownHeader from './rundown-header/RundownHeader';
|
import RundownHeader from './rundown-header/RundownHeader';
|
||||||
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
||||||
@@ -12,12 +12,16 @@ interface RundownWrapperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
|
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
|
||||||
const { data, status } = useRundown();
|
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.rundownWrapper}>
|
<div className={styles.rundownWrapper}>
|
||||||
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />}
|
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />}
|
||||||
{status === 'success' && data ? <Rundown data={data} /> : <Empty text='Connecting to server' />}
|
{status === 'success' && data && rundownMetadata ? (
|
||||||
|
<Rundown data={data} rundownMetadata={rundownMetadata} />
|
||||||
|
) : (
|
||||||
|
<Empty text='Connecting to server' />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +1,6 @@
|
|||||||
import { EntryId, OntimeDelay, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
import { EntryId, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { makeRundownMetadata, makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils';
|
import { makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils';
|
||||||
|
|
||||||
describe('makeRundownMetadata()', () => {
|
|
||||||
it('processes nested rundown data', () => {
|
|
||||||
const selectedEventId = '12';
|
|
||||||
const demoEvents = {
|
|
||||||
'1': {
|
|
||||||
id: '1',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: null,
|
|
||||||
timeStart: 0,
|
|
||||||
timeEnd: 1,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 0,
|
|
||||||
skip: false,
|
|
||||||
linkStart: false,
|
|
||||||
} as OntimeEvent,
|
|
||||||
group: {
|
|
||||||
id: 'group',
|
|
||||||
type: SupportedEntry.Group,
|
|
||||||
entries: ['11', 'delay', '12', '13'],
|
|
||||||
colour: 'red',
|
|
||||||
} as OntimeGroup,
|
|
||||||
'11': {
|
|
||||||
id: '11',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: 'group',
|
|
||||||
timeStart: 10,
|
|
||||||
timeEnd: 11,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 10,
|
|
||||||
skip: false,
|
|
||||||
linkStart: false,
|
|
||||||
} as OntimeEvent,
|
|
||||||
delay: {
|
|
||||||
id: 'delay',
|
|
||||||
type: SupportedEntry.Delay,
|
|
||||||
parent: 'group',
|
|
||||||
duration: 0,
|
|
||||||
} as OntimeDelay,
|
|
||||||
'12': {
|
|
||||||
id: '12',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: 'group',
|
|
||||||
timeStart: 11,
|
|
||||||
timeEnd: 12,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 0,
|
|
||||||
skip: false,
|
|
||||||
linkStart: true,
|
|
||||||
} as OntimeEvent,
|
|
||||||
'13': {
|
|
||||||
id: '13',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: 'group',
|
|
||||||
timeStart: 12,
|
|
||||||
timeEnd: 13,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 0,
|
|
||||||
skip: false,
|
|
||||||
linkStart: true,
|
|
||||||
} as OntimeEvent,
|
|
||||||
'2': {
|
|
||||||
id: '2',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: null,
|
|
||||||
timeStart: 20,
|
|
||||||
timeEnd: 21,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 7,
|
|
||||||
skip: false,
|
|
||||||
linkStart: false,
|
|
||||||
} as OntimeEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { metadata, process } = makeRundownMetadata(selectedEventId);
|
|
||||||
|
|
||||||
expect(metadata).toStrictEqual({
|
|
||||||
previousEvent: null,
|
|
||||||
latestEvent: null,
|
|
||||||
previousEntryId: null,
|
|
||||||
thisId: null,
|
|
||||||
eventIndex: 0,
|
|
||||||
isPast: true,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: null,
|
|
||||||
groupColour: undefined,
|
|
||||||
groupEntries: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['1'])).toStrictEqual({
|
|
||||||
previousEvent: null,
|
|
||||||
latestEvent: demoEvents['1'],
|
|
||||||
previousEntryId: null,
|
|
||||||
thisId: demoEvents['1'].id,
|
|
||||||
eventIndex: 1, // UI indexes are 1 based
|
|
||||||
isPast: true,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: null,
|
|
||||||
groupColour: undefined,
|
|
||||||
groupEntries: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['group'])).toMatchObject({
|
|
||||||
previousEvent: demoEvents['1'],
|
|
||||||
latestEvent: demoEvents['1'],
|
|
||||||
previousEntryId: demoEvents['1'].id,
|
|
||||||
thisId: demoEvents['group'].id,
|
|
||||||
eventIndex: 1,
|
|
||||||
isPast: true,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: 'group',
|
|
||||||
groupColour: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['11'])).toMatchObject({
|
|
||||||
previousEvent: demoEvents['1'],
|
|
||||||
latestEvent: demoEvents['11'],
|
|
||||||
previousEntryId: demoEvents['group'].id,
|
|
||||||
thisId: demoEvents['11'].id,
|
|
||||||
eventIndex: 2,
|
|
||||||
isPast: true,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 10,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: 'group',
|
|
||||||
groupColour: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['delay'])).toMatchObject({
|
|
||||||
previousEvent: demoEvents['11'],
|
|
||||||
latestEvent: demoEvents['11'],
|
|
||||||
previousEntryId: demoEvents['11'].id,
|
|
||||||
thisId: demoEvents['delay'].id,
|
|
||||||
eventIndex: 2,
|
|
||||||
isPast: true,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 10,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: 'group',
|
|
||||||
groupColour: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['12'])).toMatchObject({
|
|
||||||
previousEvent: demoEvents['11'],
|
|
||||||
latestEvent: demoEvents['12'],
|
|
||||||
previousEntryId: demoEvents['delay'].id,
|
|
||||||
thisId: demoEvents['12'].id,
|
|
||||||
eventIndex: 3,
|
|
||||||
isPast: false,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 10,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: true,
|
|
||||||
groupId: 'group',
|
|
||||||
groupColour: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['13'])).toMatchObject({
|
|
||||||
previousEvent: demoEvents['12'],
|
|
||||||
latestEvent: demoEvents['13'],
|
|
||||||
previousEntryId: demoEvents['12'].id,
|
|
||||||
thisId: demoEvents['13'].id,
|
|
||||||
eventIndex: 4,
|
|
||||||
isPast: false,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 10,
|
|
||||||
isLinkedToLoaded: true,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: 'group',
|
|
||||||
groupColour: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(demoEvents['2'])).toMatchObject({
|
|
||||||
previousEvent: demoEvents['13'],
|
|
||||||
latestEvent: demoEvents['2'],
|
|
||||||
previousEntryId: demoEvents['13'].id,
|
|
||||||
thisId: demoEvents['2'].id,
|
|
||||||
eventIndex: 5,
|
|
||||||
isPast: false,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 17,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: null,
|
|
||||||
groupColour: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('populates previousEntries in groups', () => {
|
|
||||||
const rundownStartsWithGroup = {
|
|
||||||
group: {
|
|
||||||
id: 'group',
|
|
||||||
type: SupportedEntry.Group,
|
|
||||||
colour: 'red',
|
|
||||||
entries: ['1', '2'],
|
|
||||||
} as OntimeGroup,
|
|
||||||
'1': {
|
|
||||||
id: '1',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: 'group',
|
|
||||||
timeStart: 1,
|
|
||||||
timeEnd: 2,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 0,
|
|
||||||
skip: false,
|
|
||||||
linkStart: false,
|
|
||||||
} as OntimeEvent,
|
|
||||||
'2': {
|
|
||||||
id: '2',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
parent: 'group',
|
|
||||||
timeStart: 2,
|
|
||||||
timeEnd: 3,
|
|
||||||
duration: 1,
|
|
||||||
dayOffset: 0,
|
|
||||||
gap: 0,
|
|
||||||
skip: false,
|
|
||||||
linkStart: false,
|
|
||||||
} as OntimeEvent,
|
|
||||||
};
|
|
||||||
const { process } = makeRundownMetadata(null);
|
|
||||||
|
|
||||||
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
|
|
||||||
previousEvent: null,
|
|
||||||
latestEvent: null,
|
|
||||||
previousEntryId: null,
|
|
||||||
thisId: rundownStartsWithGroup.group.id,
|
|
||||||
eventIndex: 0,
|
|
||||||
isPast: false,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: rundownStartsWithGroup.group.id,
|
|
||||||
groupColour: 'red',
|
|
||||||
groupEntries: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
|
|
||||||
previousEvent: null,
|
|
||||||
latestEvent: rundownStartsWithGroup['1'],
|
|
||||||
previousEntryId: rundownStartsWithGroup.group.id,
|
|
||||||
thisId: rundownStartsWithGroup['1'].id,
|
|
||||||
eventIndex: 1,
|
|
||||||
isPast: false,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: rundownStartsWithGroup.group.id,
|
|
||||||
groupColour: 'red',
|
|
||||||
groupEntries: 2,
|
|
||||||
});
|
|
||||||
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
|
|
||||||
previousEvent: rundownStartsWithGroup['1'],
|
|
||||||
latestEvent: rundownStartsWithGroup['2'],
|
|
||||||
previousEntryId: rundownStartsWithGroup['1'].id,
|
|
||||||
thisId: rundownStartsWithGroup['2'].id,
|
|
||||||
eventIndex: 2,
|
|
||||||
isPast: false,
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: rundownStartsWithGroup.group.id,
|
|
||||||
groupColour: 'red',
|
|
||||||
groupEntries: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('makeSortableList()', () => {
|
describe('makeSortableList()', () => {
|
||||||
it('generates a list with group ends', () => {
|
it('generates a list with group ends', () => {
|
||||||
@@ -327,7 +40,7 @@ describe('makeSortableList()', () => {
|
|||||||
expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']);
|
expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles a list with a with just groups', () => {
|
it('handles a list with just groups', () => {
|
||||||
const order = ['group-1', 'group-2'];
|
const order = ['group-1', 'group-2'];
|
||||||
const entries: RundownEntries = {
|
const entries: RundownEntries = {
|
||||||
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup,
|
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { isOntimeEvent, isOntimeGroup, OntimeEntry } from 'ontime-types';
|
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
|
||||||
import EventEditor from './EventEditor';
|
import EventEditor from './EventEditor';
|
||||||
import GroupEditor from './GroupEditor';
|
import GroupEditor from './GroupEditor';
|
||||||
|
import MilestoneEditor from './MilestoneEditor';
|
||||||
|
|
||||||
import style from './EntryEditor.module.scss';
|
import style from './EntryEditor.module.scss';
|
||||||
|
|
||||||
@@ -38,6 +39,14 @@ export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOntimeMilestone(entry)) {
|
||||||
|
return (
|
||||||
|
<div className={style.inModal} data-testid='editor-container'>
|
||||||
|
<MilestoneEditor milestone={entry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
if (isOntimeGroup(entry)) {
|
||||||
return (
|
return (
|
||||||
<div className={style.inModal} data-testid='editor-container'>
|
<div className={style.inModal} data-testid='editor-container'>
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
.entryEditor {
|
.cuesheetEditor,
|
||||||
|
.rundownEditor {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rundownEditor {
|
||||||
|
// width is locked to swatch picker elements
|
||||||
|
width: calc(15 * 2rem + 13 * 0.5rem);
|
||||||
|
|
||||||
|
// we dont want a scrollbar when in the modal
|
||||||
|
.content {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding-inline: 0.5rem 1.5rem;
|
padding-inline: 0.5rem 1.5rem;
|
||||||
padding-bottom: 4rem;
|
padding-bottom: 4rem;
|
||||||
@@ -13,7 +24,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeSettings {
|
.timeSettings {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function RundownEntryEditor() {
|
|||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
return (
|
return (
|
||||||
<div className={style.entryEditor} data-testid='editor-container'>
|
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||||
<EventEditor event={entry} />
|
<EventEditor event={entry} />
|
||||||
<EventEditorFooter id={entry.id} cue={entry.cue} />
|
<EventEditorFooter id={entry.id} cue={entry.cue} />
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,7 @@ export default function RundownEntryEditor() {
|
|||||||
|
|
||||||
if (isOntimeMilestone(entry)) {
|
if (isOntimeMilestone(entry)) {
|
||||||
return (
|
return (
|
||||||
<div className={style.entryEditor} data-testid='editor-container'>
|
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||||
<MilestoneEditor milestone={entry} />
|
<MilestoneEditor milestone={entry} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -69,7 +69,7 @@ export default function RundownEntryEditor() {
|
|||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
if (isOntimeGroup(entry)) {
|
||||||
return (
|
return (
|
||||||
<div className={style.entryEditor} data-testid='editor-container'>
|
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||||
<GroupEditor group={entry} />
|
<GroupEditor group={entry} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
.triggerForm {
|
.triggerForm {
|
||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr auto auto;
|
grid-template-columns: 8rem 1fr auto 2rem;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
.trigger {
|
.trigger {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr auto;
|
grid-template-columns: 8rem 1fr 2rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&:nth-child(even) {
|
&:nth-child(even) {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
|
|||||||
<div key={id} className={style.trigger}>
|
<div key={id} className={style.trigger}>
|
||||||
<Tag>{triggerLifeCycle}</Tag>
|
<Tag>{triggerLifeCycle}</Tag>
|
||||||
<Tag>{automationTitle}</Tag>
|
<Tag>{automationTitle}</Tag>
|
||||||
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
|
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
||||||
<IoTrash />
|
<IoTrash />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: $blue-500;
|
background: $blue-500;
|
||||||
|
z-index: $zindex-floating;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addButton {
|
.addButton {
|
||||||
|
|||||||
@@ -9,68 +9,53 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
|||||||
import style from './QuickAddInline.module.scss';
|
import style from './QuickAddInline.module.scss';
|
||||||
|
|
||||||
interface QuickAddInlineProps {
|
interface QuickAddInlineProps {
|
||||||
previousEventId: MaybeString;
|
referenceEntryId: MaybeString;
|
||||||
parentGroup: MaybeString;
|
parentGroup: MaybeString;
|
||||||
|
placement: 'before' | 'after';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(QuickAddInline);
|
export default memo(QuickAddInline);
|
||||||
function QuickAddInline({ previousEventId, parentGroup }: QuickAddInlineProps) {
|
function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) {
|
||||||
const { addEntry } = useEntryActions();
|
const { addEntry } = useEntryActions();
|
||||||
|
|
||||||
const addEvent = () => {
|
const handleAddEntry = (type: SupportedEntry) => {
|
||||||
addEntry(
|
if (placement === 'before') {
|
||||||
{
|
addEntry(
|
||||||
type: SupportedEntry.Event,
|
{ type, parent: type !== SupportedEntry.Group ? parentGroup : null },
|
||||||
parent: parentGroup,
|
{
|
||||||
},
|
before: referenceEntryId,
|
||||||
{
|
},
|
||||||
after: previousEventId,
|
);
|
||||||
lastEventId: previousEventId,
|
} else {
|
||||||
},
|
addEntry(
|
||||||
);
|
{ type, parent: type !== SupportedEntry.Group ? parentGroup : null },
|
||||||
};
|
{
|
||||||
|
lastEventId: referenceEntryId,
|
||||||
const addDelay = () => {
|
after: referenceEntryId,
|
||||||
addEntry(
|
},
|
||||||
{ type: SupportedEntry.Delay, parent: parentGroup },
|
);
|
||||||
{
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
after: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMilestone = () => {
|
|
||||||
addEntry(
|
|
||||||
{ type: SupportedEntry.Milestone, parent: parentGroup },
|
|
||||||
{
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
after: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addGroup = () => {
|
|
||||||
if (parentGroup !== null) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
addEntry(
|
|
||||||
{ type: SupportedEntry.Group },
|
|
||||||
{
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
after: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.quickAdd} data-testid='quick-add-inline'>
|
<div className={style.quickAdd} data-testid='quick-add-inline'>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
items={[
|
items={[
|
||||||
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: addEvent },
|
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: () => handleAddEntry(SupportedEntry.Event) },
|
||||||
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: addDelay },
|
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: () => handleAddEntry(SupportedEntry.Delay) },
|
||||||
{ type: 'item', icon: IoAdd, label: 'Add Milestone', onClick: addMilestone },
|
{
|
||||||
{ type: 'item', icon: IoAdd, label: 'Add Group', onClick: addGroup, disabled: parentGroup !== null },
|
type: 'item',
|
||||||
|
icon: IoAdd,
|
||||||
|
label: 'Add Milestone',
|
||||||
|
onClick: () => handleAddEntry(SupportedEntry.Milestone),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoAdd,
|
||||||
|
label: 'Add Group',
|
||||||
|
onClick: () => handleAddEntry(SupportedEntry.Group),
|
||||||
|
disabled: parentGroup !== null,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
render={<IconButton size='small' variant='primary' className={style.addButton} />}
|
render={<IconButton size='small' variant='primary' className={style.addButton} />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled } from 'react-icons/tb';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { isPlaybackActive } from 'ontime-utils';
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import type { EventItemActions } from '../RundownEntry';
|
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
@@ -56,15 +56,7 @@ interface RundownEventProps {
|
|||||||
dayOffset: number;
|
dayOffset: number;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
actionHandler: (
|
createCloneEvent: () => void;
|
||||||
action: EventItemActions,
|
|
||||||
payload?:
|
|
||||||
| number
|
|
||||||
| {
|
|
||||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
|
||||||
value: unknown;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
hasTriggers: boolean;
|
hasTriggers: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,11 +90,13 @@ export default function RundownEvent({
|
|||||||
dayOffset,
|
dayOffset,
|
||||||
totalGap,
|
totalGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
actionHandler,
|
|
||||||
hasTriggers,
|
hasTriggers,
|
||||||
|
createCloneEvent,
|
||||||
}: RundownEventProps) {
|
}: RundownEventProps) {
|
||||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||||
const { selectedEvents, setSelectedEvents } = useEventSelection();
|
const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
|
||||||
|
|
||||||
|
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
@@ -113,37 +107,48 @@ export default function RundownEvent({
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Link to previous',
|
label: 'Link to previous',
|
||||||
icon: IoLink,
|
icon: IoLink,
|
||||||
onClick: () =>
|
onClick: () => {
|
||||||
actionHandler('update', {
|
batchUpdateEvents({ linkStart: true }, Array.from(selectedEvents));
|
||||||
field: 'linkStart',
|
},
|
||||||
value: 'true',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Unlink from previous',
|
label: 'Unlink from previous',
|
||||||
icon: IoUnlink,
|
icon: IoUnlink,
|
||||||
onClick: () =>
|
onClick: () => {
|
||||||
actionHandler('update', {
|
batchUpdateEvents({ linkStart: false }, Array.from(selectedEvents));
|
||||||
field: 'linkStart',
|
},
|
||||||
value: null,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('make-group') },
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Group',
|
||||||
|
icon: IoFolder,
|
||||||
|
onClick: () => {
|
||||||
|
groupEntries(Array.from(selectedEvents));
|
||||||
|
clearSelectedEvents();
|
||||||
|
},
|
||||||
|
disabled: parent !== null,
|
||||||
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Delete',
|
||||||
|
icon: IoTrash,
|
||||||
|
onClick: () => {
|
||||||
|
clearSelectedEvents();
|
||||||
|
deleteEntry(Array.from(selectedEvents));
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: flag ? 'Remove flag' : 'Add flag',
|
label: flag ? 'Remove flag' : 'Add flag',
|
||||||
icon: TbFlagFilled,
|
icon: TbFlagFilled,
|
||||||
onClick: () =>
|
onClick: () => {
|
||||||
actionHandler('update', {
|
updateEntry({ id: eventId, flag: !flag });
|
||||||
field: 'flag',
|
},
|
||||||
value: !flag,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
@@ -157,7 +162,8 @@ export default function RundownEvent({
|
|||||||
label: `Swap this event with ${selectedEventId ?? ''}`,
|
label: `Swap this event with ${selectedEventId ?? ''}`,
|
||||||
icon: IoSwapVertical,
|
icon: IoSwapVertical,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
actionHandler('swap', { field: 'id', value: selectedEventId });
|
if (!selectedEventId) return;
|
||||||
|
swapEvents(selectedEventId, eventId);
|
||||||
clearSelectedEventId();
|
clearSelectedEventId();
|
||||||
},
|
},
|
||||||
disabled: selectedEventId == null || selectedEventId === eventId,
|
disabled: selectedEventId == null || selectedEventId === eventId,
|
||||||
@@ -166,10 +172,18 @@ export default function RundownEvent({
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Clone',
|
label: 'Clone',
|
||||||
icon: IoDuplicateOutline,
|
icon: IoDuplicateOutline,
|
||||||
onClick: () => actionHandler('clone'),
|
onClick: createCloneEvent,
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Delete',
|
||||||
|
icon: IoTrash,
|
||||||
|
onClick: () => {
|
||||||
|
deleteEntry([eventId]);
|
||||||
|
unselect(eventId);
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||||
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||||
|
|
||||||
const [planOffset, planOffsetLabel] = (() => {
|
const [planOffset, planOffsetLabel] = (() => {
|
||||||
if (data.targetDuration === null) {
|
if (data.targetDuration === null) {
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
.milestone {
|
.milestone {
|
||||||
@include block-styling;
|
@include block-styling;
|
||||||
|
|
||||||
margin-left: calc(2rem + 1px); // binder + border
|
|
||||||
margin-block: 0.125rem;
|
margin-block: 0.125rem;
|
||||||
padding-right: 0.25rem;
|
padding-right: 0.25rem;
|
||||||
background-color: $gray-1050; // to override inline
|
background-color: $gray-1050; // to override inline
|
||||||
color: $section-white; // to override inline
|
color: $section-white; // to override inline
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem 1fr 3fr;
|
grid-template-columns: 2rem 0.4fr 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: $secondary-block-height;
|
height: $secondary-block-height;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -1,129 +1,4 @@
|
|||||||
import {
|
import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||||
EntryId,
|
|
||||||
isOntimeEvent,
|
|
||||||
isOntimeGroup,
|
|
||||||
isPlayableEvent,
|
|
||||||
MaybeString,
|
|
||||||
OntimeDelay,
|
|
||||||
OntimeEntry,
|
|
||||||
OntimeEvent,
|
|
||||||
OntimeMilestone,
|
|
||||||
PlayableEvent,
|
|
||||||
RundownEntries,
|
|
||||||
SupportedEntry,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
|
||||||
|
|
||||||
type RundownMetadata = {
|
|
||||||
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
|
|
||||||
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
|
||||||
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
|
|
||||||
thisId: MaybeString;
|
|
||||||
eventIndex: number;
|
|
||||||
isPast: boolean;
|
|
||||||
isNextDay: boolean;
|
|
||||||
totalGap: number;
|
|
||||||
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
|
||||||
isLoaded: boolean;
|
|
||||||
groupId: MaybeString;
|
|
||||||
groupColour: string | undefined;
|
|
||||||
groupEntries: number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a process function which aggregates the rundown metadata and event metadata
|
|
||||||
*/
|
|
||||||
export function makeRundownMetadata(selectedEventId: MaybeString) {
|
|
||||||
let rundownMeta: RundownMetadata = {
|
|
||||||
previousEvent: null,
|
|
||||||
latestEvent: null,
|
|
||||||
previousEntryId: null,
|
|
||||||
thisId: null,
|
|
||||||
eventIndex: 0,
|
|
||||||
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
|
||||||
isNextDay: false,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
isLoaded: false,
|
|
||||||
groupId: null,
|
|
||||||
groupColour: undefined,
|
|
||||||
groupEntries: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
|
||||||
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
|
|
||||||
rundownMeta = processedRundownMetadata;
|
|
||||||
return rundownMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { metadata: rundownMeta, process };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives a rundown entry and processes its place in the rundown
|
|
||||||
*/
|
|
||||||
function processEntry(
|
|
||||||
rundownMetadata: RundownMetadata,
|
|
||||||
selectedEventId: MaybeString,
|
|
||||||
entry: Readonly<OntimeEntry>,
|
|
||||||
): Readonly<RundownMetadata> {
|
|
||||||
const processedData = { ...rundownMetadata };
|
|
||||||
// initialise data to be overridden below
|
|
||||||
processedData.isNextDay = false;
|
|
||||||
processedData.isLoaded = false;
|
|
||||||
|
|
||||||
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
|
||||||
processedData.thisId = entry.id; // we reassign thisId
|
|
||||||
processedData.previousEvent = processedData.latestEvent;
|
|
||||||
|
|
||||||
if (entry.id === selectedEventId) {
|
|
||||||
processedData.isLoaded = true;
|
|
||||||
processedData.isPast = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
|
||||||
processedData.groupId = entry.id;
|
|
||||||
processedData.groupColour = entry.colour;
|
|
||||||
processedData.groupEntries = entry.entries.length;
|
|
||||||
} else {
|
|
||||||
// for delays and groups, we insert the group metadata
|
|
||||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
|
||||||
// if the parent is not the current group, we need to update the groupId
|
|
||||||
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
|
|
||||||
processedData.groupEntries = undefined;
|
|
||||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) {
|
|
||||||
// if the entry has no parent, it cannot have a group colour
|
|
||||||
processedData.groupColour = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
|
||||||
// event indexes are 1 based in UI
|
|
||||||
processedData.eventIndex += 1;
|
|
||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
|
||||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
|
||||||
processedData.totalGap += entry.gap;
|
|
||||||
|
|
||||||
if (!processedData.isPast && !processedData.isLoaded) {
|
|
||||||
/**
|
|
||||||
* isLinkToLoaded is a chain value that we maintain until we
|
|
||||||
* a) find an unlinked event
|
|
||||||
* b) find a countToEnd event
|
|
||||||
*/
|
|
||||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
|
||||||
// this event is the forward most event in rundown, for next iteration
|
|
||||||
processedData.latestEvent = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a sortable list of entries
|
* Creates a sortable list of entries
|
||||||
@@ -160,14 +35,23 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
|
|||||||
* Checks whether a drop operation is valid
|
* Checks whether a drop operation is valid
|
||||||
* Currently only used for validating dropping groups
|
* Currently only used for validating dropping groups
|
||||||
*/
|
*/
|
||||||
export function canDrop(targetType?: SupportedEntry | 'end-group', targetParent?: EntryId | null): boolean {
|
export function canDrop(
|
||||||
|
targetType: SupportedEntry | 'end-group',
|
||||||
|
targetParent: EntryId | null,
|
||||||
|
order?: 'after' | 'before',
|
||||||
|
isTargetCollapsed?: boolean,
|
||||||
|
): boolean {
|
||||||
// this would mean inserting a group inside another
|
// this would mean inserting a group inside another
|
||||||
if (targetType === 'end-group') {
|
if (targetType === 'end-group') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this means swapping places with another group
|
// this means swapping places with another group
|
||||||
|
// !!! if the user is dragging down, they could be inserting into a group depending on whether the group is collapsed
|
||||||
if (targetType === 'group') {
|
if (targetType === 'group') {
|
||||||
|
if (order !== undefined && order === 'after' && !isTargetCollapsed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$track-color: $white-1;
|
$track-color: $white-1;
|
||||||
$thumb-color: $gray-1100;
|
$thumb-color: $white-20;
|
||||||
$thumb-color-hover: $gray-900;
|
$thumb-color-hover: $white-60;
|
||||||
|
|
||||||
/* Apply a natural box layout model to all elements */
|
/* Apply a natural box layout model to all elements */
|
||||||
html {
|
html {
|
||||||
@@ -157,7 +157,7 @@ input[type='number'] {
|
|||||||
|
|
||||||
/* Track */
|
/* Track */
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: $white-1;
|
background: $track-color;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createContext, PropsWithChildren, useCallback, useContext } from 'react';
|
import { createContext, PropsWithChildren, useCallback, useContext } from 'react';
|
||||||
|
import { langEn, TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postUserTranslation } from '../common/api/assets';
|
||||||
|
import { useCustomTranslation } from '../common/hooks-query/useCustomTranslation';
|
||||||
import useSettings from '../common/hooks-query/useSettings';
|
import useSettings from '../common/hooks-query/useSettings';
|
||||||
|
|
||||||
import { langDe } from './languages/de';
|
import { langDe } from './languages/de';
|
||||||
import { langEn } from './languages/en';
|
|
||||||
import { langEs } from './languages/es';
|
import { langEs } from './languages/es';
|
||||||
import { langFr } from './languages/fr';
|
import { langFr } from './languages/fr';
|
||||||
import { langIt } from './languages/it';
|
import { langIt } from './languages/it';
|
||||||
@@ -21,15 +23,20 @@ const translationsList = {
|
|||||||
export type TranslationKey = keyof typeof langEn;
|
export type TranslationKey = keyof typeof langEn;
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
|
userTranslation: TranslationObject;
|
||||||
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
||||||
|
postUserTranslation: (translation: TranslationObject) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranslationContext = createContext<TranslationContextValue>({
|
const TranslationContext = createContext<TranslationContextValue>({
|
||||||
|
userTranslation: langEn,
|
||||||
getLocalizedString: () => '',
|
getLocalizedString: () => '',
|
||||||
|
postUserTranslation: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
||||||
const { data } = useSettings();
|
const { data } = useSettings();
|
||||||
|
const { data: translationData } = useCustomTranslation();
|
||||||
|
|
||||||
const getLocalizedString = useCallback(
|
const getLocalizedString = useCallback(
|
||||||
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
||||||
@@ -37,20 +44,24 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
|||||||
if (key in translationsList[lang as keyof typeof translationsList]) {
|
if (key in translationsList[lang as keyof typeof translationsList]) {
|
||||||
return translationsList[lang as keyof typeof translationsList][key];
|
return translationsList[lang as keyof typeof translationsList][key];
|
||||||
}
|
}
|
||||||
|
} else if (lang === 'custom') {
|
||||||
|
return translationData[key];
|
||||||
}
|
}
|
||||||
return langEn[key];
|
return langEn[key];
|
||||||
},
|
},
|
||||||
[data?.language],
|
[data?.language, translationData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
|
userTranslation: translationData,
|
||||||
getLocalizedString,
|
getLocalizedString,
|
||||||
|
postUserTranslation,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTranslation = () => {
|
export const useTranslation = () => {
|
||||||
const { getLocalizedString } = useContext(TranslationContext);
|
const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext);
|
||||||
return { getLocalizedString };
|
return { userTranslation, getLocalizedString, postUserTranslation };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langDe: TranslationObject = {
|
export const langDe: TranslationObject = {
|
||||||
'common.expected_finish': 'Erwartetes Ende',
|
'common.expected_finish': 'Erwartetes Ende',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langEs: TranslationObject = {
|
export const langEs: TranslationObject = {
|
||||||
'common.expected_finish': 'Finalización esperada',
|
'common.expected_finish': 'Finalización esperada',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langFr: TranslationObject = {
|
export const langFr: TranslationObject = {
|
||||||
'common.expected_finish': 'Fin estimée à',
|
'common.expected_finish': 'Fin estimée à',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langIt: TranslationObject = {
|
export const langIt: TranslationObject = {
|
||||||
'common.expected_finish': 'Fine Prevista',
|
'common.expected_finish': 'Fine Prevista',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langPt: TranslationObject = {
|
export const langPt: TranslationObject = {
|
||||||
'common.expected_finish': 'Término esperado',
|
'common.expected_finish': 'Término esperado',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getCountdownOptions, useCountdownOptions } from './countdown.options';
|
|||||||
import { getOrderedSubscriptions } from './countdown.utils';
|
import { getOrderedSubscriptions } from './countdown.utils';
|
||||||
import CountdownSelect from './CountdownSelect';
|
import CountdownSelect from './CountdownSelect';
|
||||||
import CountdownSubscriptions from './CountdownSubscriptions';
|
import CountdownSubscriptions from './CountdownSubscriptions';
|
||||||
|
import SingleEventCountdown from './SingleEventCountdown';
|
||||||
import { CountdownData, useCountdownData } from './useCountdownData';
|
import { CountdownData, useCountdownData } from './useCountdownData';
|
||||||
|
|
||||||
import './Countdown.scss';
|
import './Countdown.scss';
|
||||||
@@ -116,6 +117,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subscribedEvents.length === 1) {
|
||||||
|
const event = subscribedEvents.at(0);
|
||||||
|
if (!event) return null;
|
||||||
|
return <SingleEventCountdown subscribedEvent={event} goToEditMode={goToEditMode} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />;
|
return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
.single-container {
|
||||||
|
height: 100%;
|
||||||
|
margin-top: 5vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $view-element-gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event__title {
|
||||||
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
|
padding: $view-card-padding;
|
||||||
|
border-radius: $element-border-radius;
|
||||||
|
font-size: clamp(40px, 4.5vw, 80px);
|
||||||
|
line-height: 1.1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event__status {
|
||||||
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
|
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event__timer {
|
||||||
|
color: var(--timer-color-override, $timer-color);
|
||||||
|
font-size: 15vw;
|
||||||
|
line-height: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import Button from '../../common/components/buttons/Button';
|
||||||
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
|
|
||||||
|
import { useCountdownOptions } from './countdown.options';
|
||||||
|
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils';
|
||||||
|
|
||||||
|
import './SingleEventCountdown.scss';
|
||||||
|
|
||||||
|
interface SingleEventCountdownProps {
|
||||||
|
subscribedEvent: OntimeEvent;
|
||||||
|
goToEditMode: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
|
||||||
|
const showFab = useFadeOutOnInactivity(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='single-container' data-testid='countdown-event'>
|
||||||
|
<SubscriptionStatus event={subscribedEvent} />
|
||||||
|
<div className='event__title'>{subscribedEvent.title}</div>
|
||||||
|
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
||||||
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||||
|
<IoPencil /> Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionStatusProps {
|
||||||
|
event: OntimeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
|
const { getLocalizedString } = useTranslation();
|
||||||
|
const { selectedEventId } = useSelectedEventId();
|
||||||
|
const { currentDay } = useCurrentDay();
|
||||||
|
const { offset } = useRuntimeOffset();
|
||||||
|
const { showExpected } = useCountdownOptions();
|
||||||
|
const { playback, current, clock } = useCountdownSocket();
|
||||||
|
|
||||||
|
// TODO: use reporter values as in the event block chip
|
||||||
|
const { status, timer } = getSubscriptionDisplayData(
|
||||||
|
current,
|
||||||
|
playback,
|
||||||
|
clock,
|
||||||
|
event,
|
||||||
|
selectedEventId,
|
||||||
|
offset,
|
||||||
|
currentDay,
|
||||||
|
getLocalizedString('common.minutes'),
|
||||||
|
showExpected,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||||
|
<div className='event__timer'>{timer}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useSessionStorage } from '@mantine/hooks';
|
|||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
|
||||||
import { sessionScope } from '../../externals';
|
import { sessionScope } from '../../externals';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import { useCuesheetPermissions } from './useTablePermissions';
|
|||||||
|
|
||||||
export default memo(CuesheetTableWrapper);
|
export default memo(CuesheetTableWrapper);
|
||||||
function CuesheetTableWrapper() {
|
function CuesheetTableWrapper() {
|
||||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
const preset = use(PresetContext);
|
const preset = use(PresetContext);
|
||||||
@@ -52,15 +50,11 @@ function CuesheetTableWrapper() {
|
|||||||
[customFields, cuesheetMode, preset],
|
[customFields, cuesheetMode, preset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CuesheetDnd columns={columns}>
|
<CuesheetDnd columns={columns}>
|
||||||
{isLoading ? (
|
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
|
||||||
<EmptyPage text='Loading...' />
|
|
||||||
) : (
|
|
||||||
<CuesheetTable data={flatRundown} columns={columns} cuesheetMode={cuesheetMode} />
|
|
||||||
)}
|
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import useColumnManager from '../cuesheet-table/useColumnManager';
|
import useColumnManager from '../cuesheet-table/useColumnManager';
|
||||||
|
|
||||||
interface CuesheetDndProps {
|
interface CuesheetDndProps {
|
||||||
columns: ColumnDef<OntimeEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
|
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
$table-font-size: 1rem;
|
$table-font-size: 1rem;
|
||||||
$table-header-font-size: calc(1rem - 2px);
|
$table-header-font-size: calc(1rem - 2px);
|
||||||
|
|
||||||
.cuesheetContainer {
|
|
||||||
grid-area: table;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
padding-bottom: 70vh; // allow focus to reach last elements
|
|
||||||
}
|
|
||||||
|
|
||||||
.cuesheet {
|
.cuesheet {
|
||||||
font-size: $table-font-size;
|
font-size: $table-font-size;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
padding-bottom: 70vh; // allow focus to reach last elements
|
||||||
|
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -30,10 +32,6 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
width: 0.5rem;
|
width: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:first-of-type {
|
|
||||||
margin-left: 4px; // compensate left border
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
@@ -82,7 +80,7 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
|
|
||||||
.actionColumn {
|
.actionColumn {
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
width: calc(2rem + 1px); // button + padding + margin
|
width: 2rem; // button + padding + margin
|
||||||
}
|
}
|
||||||
.indexColumn {
|
.indexColumn {
|
||||||
width: 3.5em;
|
width: 3.5em;
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { memo, useCallback, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, TimeField } from 'ontime-types';
|
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
|
||||||
|
|
||||||
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
|
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { useFollowSelected } from '../../../common/hooks/useFollowComponent';
|
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||||
|
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
||||||
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
|
|
||||||
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
|
||||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||||
|
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||||
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
|
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||||
|
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import useColumnManager from './useColumnManager';
|
import useColumnManager from './useColumnManager';
|
||||||
@@ -17,19 +25,20 @@ import useColumnManager from './useColumnManager';
|
|||||||
import style from './CuesheetTable.module.scss';
|
import style from './CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableProps {
|
interface CuesheetTableProps {
|
||||||
data: OntimeEntry[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
columns: ColumnDef<OntimeEntry>[];
|
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetTableProps) {
|
export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTableProps) {
|
||||||
|
const { data, status } = useFlatRundownWithMetadata();
|
||||||
const { updateEntry, updateTimer } = useEntryActions();
|
const { updateEntry, updateTimer } = useEntryActions();
|
||||||
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
||||||
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
||||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
|
||||||
const { selectedRef, scrollRef } = useFollowSelected(cuesheetMode === AppMode.Run);
|
const { selectedEventId } = useSelectedEventId();
|
||||||
|
|
||||||
|
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||||
const { listeners } = useTableNav();
|
const { listeners } = useTableNav();
|
||||||
|
|
||||||
const meta = useMemo(
|
const meta = useMemo(
|
||||||
@@ -96,9 +105,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
}, [setColumnSizing]);
|
}, [setColumnSizing]);
|
||||||
|
|
||||||
const headerGroups = table.getHeaderGroups();
|
// in run mode, we follow the selected row
|
||||||
const rowModel = table.getRowModel();
|
useEffect(() => {
|
||||||
const allLeafColumns = table.getAllLeafColumns();
|
if (cuesheetMode === AppMode.Edit || virtuosoRef.current === null || !selectedEventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth' });
|
||||||
|
}, [cuesheetMode, data, selectedEventId]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* To improve performance on resizing, we memoise the column sizes
|
* To improve performance on resizing, we memoise the column sizes
|
||||||
@@ -118,6 +133,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||||
|
|
||||||
|
const allLeafColumns = table.getAllLeafColumns();
|
||||||
|
const { rows } = table.getRowModel();
|
||||||
|
|
||||||
|
const isLoading = !data || status === 'pending';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <EmptyPage text='Loading...' />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CuesheetTableSettings
|
<CuesheetTableSettings
|
||||||
@@ -126,25 +150,97 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
|
|||||||
handleResetReordering={resetColumnOrder}
|
handleResetReordering={resetColumnOrder}
|
||||||
handleClearToggles={setAllVisible}
|
handleClearToggles={setAllVisible}
|
||||||
/>
|
/>
|
||||||
<div className={style.cuesheetContainer} ref={scrollRef}>
|
<TableVirtuoso
|
||||||
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
ref={virtuosoRef}
|
||||||
<CuesheetHeader headerGroups={headerGroups} cuesheetMode={cuesheetMode} />
|
data={data}
|
||||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
increaseViewportBy={{ top: 100, bottom: 200 }}
|
||||||
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
components={{
|
||||||
) : (
|
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
|
||||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
Table: ({ style: injectedStyles, ...virtuosoProps }) => {
|
||||||
)}
|
return (
|
||||||
</table>
|
<table
|
||||||
</div>
|
className={style.cuesheet}
|
||||||
|
id='cuesheet'
|
||||||
|
style={{ ...injectedStyles, ...columnSizeVars }}
|
||||||
|
{...listeners}
|
||||||
|
{...virtuosoProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TableRow: ({ item: _item, ...virtuosoProps }) => {
|
||||||
|
// eslint-disable-next-line react/destructuring-assignment
|
||||||
|
const rowIndex = virtuosoProps['data-index'];
|
||||||
|
const row = rows[rowIndex];
|
||||||
|
const key = row.original.id;
|
||||||
|
const entry = row.original;
|
||||||
|
|
||||||
|
if (isOntimeGroup(entry)) {
|
||||||
|
return (
|
||||||
|
<GroupRow
|
||||||
|
key={key}
|
||||||
|
groupId={entry.id}
|
||||||
|
colour={entry.colour}
|
||||||
|
rowId={row.id}
|
||||||
|
rowIndex={row.index}
|
||||||
|
table={table}
|
||||||
|
{...virtuosoProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeDelay(entry)) {
|
||||||
|
return <DelayRow key={key} duration={entry.duration} {...virtuosoProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeMilestone(entry)) {
|
||||||
|
return (
|
||||||
|
<MilestoneRow
|
||||||
|
key={key}
|
||||||
|
entryId={entry.id}
|
||||||
|
isPast={entry.isPast}
|
||||||
|
parentBgColour={entry.groupColour}
|
||||||
|
parentId={entry.parent}
|
||||||
|
colour={entry.colour}
|
||||||
|
rowId={row.id}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
table={table}
|
||||||
|
{...virtuosoProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventRow
|
||||||
|
key={row.id}
|
||||||
|
id={entry.id}
|
||||||
|
eventIndex={entry.eventIndex}
|
||||||
|
colour={entry.colour}
|
||||||
|
isFirstAfterGroup={entry.isFirstAfterGroup}
|
||||||
|
isLoaded={entry.isLoaded}
|
||||||
|
isPast={entry.isPast}
|
||||||
|
groupColour={entry.groupColour}
|
||||||
|
flag={entry.flag}
|
||||||
|
skip={entry.skip}
|
||||||
|
parent={entry.parent}
|
||||||
|
rowId={row.id}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
table={table}
|
||||||
|
{...virtuosoProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
|
||||||
|
}}
|
||||||
|
fixedHeaderContent={() => {
|
||||||
|
return table
|
||||||
|
.getHeaderGroups()
|
||||||
|
.map((headerGroup) => (
|
||||||
|
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<CuesheetTableMenu />
|
<CuesheetTableMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* While dragging, we avoid re-rendering the body by render
|
|
||||||
*/
|
|
||||||
const MemoisedBody = memo(
|
|
||||||
CuesheetBody,
|
|
||||||
(prev, next) => prev.table.options.data === next.table.options.data,
|
|
||||||
) as typeof CuesheetBody;
|
|
||||||
|
|||||||
-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 { CSSProperties } from 'react';
|
||||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
||||||
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
@@ -12,54 +12,45 @@ import { SortableCell } from './SortableCell';
|
|||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface CuesheetHeaderProps {
|
interface CuesheetHeaderProps {
|
||||||
headerGroups: HeaderGroup<OntimeEntry>[];
|
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) {
|
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
|
||||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<thead className={style.tableHeader}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroups.map((headerGroup) => {
|
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
||||||
const key = headerGroup.id;
|
{!hideIndexColumn && (
|
||||||
|
<th className={style.indexColumn} tabIndex={-1}>
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<SortableContext key={headerGroup.id} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const customBackground = header.column.columnDef.meta?.colour;
|
||||||
|
const canWrite = header.column.columnDef.meta?.canWrite;
|
||||||
|
|
||||||
return (
|
const customStyles: CSSProperties = {
|
||||||
<tr key={headerGroup.id}>
|
opacity: canWrite ? 1 : 0.6,
|
||||||
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
};
|
||||||
{!hideIndexColumn && (
|
if (customBackground) {
|
||||||
<th className={style.indexColumn} tabIndex={-1}>
|
const customColour = getAccessibleColour(customBackground);
|
||||||
#
|
customStyles.backgroundColor = customColour.backgroundColor;
|
||||||
</th>
|
customStyles.color = customColour.color;
|
||||||
)}
|
}
|
||||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const customBackground = header.column.columnDef.meta?.colour;
|
|
||||||
const canWrite = header.column.columnDef.meta?.canWrite;
|
|
||||||
|
|
||||||
const customStyles: CSSProperties = {
|
return (
|
||||||
opacity: canWrite ? 1 : 0.6,
|
<SortableCell
|
||||||
};
|
key={header.column.columnDef.id}
|
||||||
if (customBackground) {
|
header={header}
|
||||||
const customColour = getAccessibleColour(customBackground);
|
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||||
customStyles.backgroundColor = customColour.backgroundColor;
|
>
|
||||||
customStyles.color = customColour.color;
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
}
|
</SortableCell>
|
||||||
|
);
|
||||||
return (
|
})}
|
||||||
<SortableCell
|
</SortableContext>
|
||||||
key={header.column.columnDef.id}
|
</tr>
|
||||||
header={header}
|
|
||||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</SortableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</thead>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -1,15 +1,13 @@
|
|||||||
@import '../CuesheetTable.module.scss';
|
@import '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
.delayRow {
|
.delayRow {
|
||||||
width: calc(100vw - 2rem);
|
|
||||||
color: $ontime-delay-text;
|
color: $ontime-delay-text;
|
||||||
border-left: 4px solid var(--user-bg);
|
border-left: 4px solid transparent;
|
||||||
|
|
||||||
td {
|
td {
|
||||||
width: 100%;
|
width: calc(100% - 4px);
|
||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: translateX(45%);
|
|
||||||
|
|
||||||
&:first-letter {
|
&:first-letter {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
+10
-12
@@ -1,28 +1,26 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
|
|
||||||
import style from './DelayRow.module.scss';
|
import style from './DelayRow.module.scss';
|
||||||
|
|
||||||
interface DelayRowProps {
|
interface DelayRowProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
parentBgColour: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DelayRow({ duration, parentBgColour }: DelayRowProps) {
|
function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
|
||||||
|
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||||
|
|
||||||
|
if (hideDelays || duration === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const delayTime = millisToDelayString(duration, 'expanded');
|
const delayTime = millisToDelayString(duration, 'expanded');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr className={style.delayRow} data-testid='cuesheet-delay' {...virtuosoProps}>
|
||||||
className={style.delayRow}
|
<td tabIndex={0}>{delayTime}</td>
|
||||||
style={{
|
|
||||||
'--user-bg': parentBgColour ?? 'transparent',
|
|
||||||
}}
|
|
||||||
data-testid='cuesheet-delay'
|
|
||||||
>
|
|
||||||
<td tabIndex={0} role='cell'>
|
|
||||||
{delayTime}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.firstAfterGroup {
|
&.firstAfterGroup {
|
||||||
margin-top: 1rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.skip {
|
&.skip {
|
||||||
|
|||||||
+64
-64
@@ -1,89 +1,91 @@
|
|||||||
import { RefObject, useEffect, useRef } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types';
|
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
|
|
||||||
import { observeRow, unobserveRow } from './rowObserver';
|
|
||||||
import { useVisibleRowsStore } from './visibleRowsStore';
|
|
||||||
|
|
||||||
import style from './EventRow.module.scss';
|
import style from './EventRow.module.scss';
|
||||||
|
|
||||||
interface EventRowProps {
|
interface EventRowProps {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
event: OntimeEvent;
|
id: EntryId;
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
|
colour: string;
|
||||||
|
isFirstAfterGroup: boolean;
|
||||||
|
isLoaded: boolean;
|
||||||
|
isPast: boolean;
|
||||||
|
groupColour: string | undefined;
|
||||||
|
flag: boolean;
|
||||||
|
skip: boolean;
|
||||||
|
parent: EntryId | null;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
isPast?: boolean;
|
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||||
selectedRef?: RefObject<HTMLTableRowElement | null>;
|
|
||||||
skip?: boolean;
|
|
||||||
colour?: string;
|
|
||||||
rowBgColour?: string;
|
|
||||||
parentBgColour?: string;
|
|
||||||
table: Table<OntimeEntry>;
|
|
||||||
firstAfterGroup: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventRow({
|
export default function EventRow({
|
||||||
rowId,
|
rowId,
|
||||||
event,
|
id,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
rowIndex,
|
colour,
|
||||||
|
isFirstAfterGroup,
|
||||||
|
isLoaded,
|
||||||
isPast,
|
isPast,
|
||||||
selectedRef,
|
groupColour,
|
||||||
rowBgColour,
|
flag,
|
||||||
parentBgColour,
|
skip,
|
||||||
|
parent,
|
||||||
|
rowIndex,
|
||||||
table,
|
table,
|
||||||
firstAfterGroup,
|
...virtuosoProps
|
||||||
}: EventRowProps) {
|
}: EventRowProps) {
|
||||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||||
cuesheetMode: AppMode.Edit,
|
cuesheetMode: AppMode.Edit,
|
||||||
hideIndexColumn: false,
|
hideIndexColumn: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
|
||||||
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
// register this row with the intersection observer
|
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||||
useEffect(() => {
|
|
||||||
const element = ownRef.current;
|
|
||||||
if (element) {
|
|
||||||
element.id = rowId;
|
|
||||||
observeRow(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (element) {
|
|
||||||
unobserveRow(element);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [rowId]);
|
|
||||||
|
|
||||||
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
|
||||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||||
|
|
||||||
|
const rowBgColour: string | undefined = useMemo(() => {
|
||||||
|
if (isLoaded) {
|
||||||
|
return '#087A27'; // $active-green
|
||||||
|
} else if (colour) {
|
||||||
|
// the colour is user defined and might be invalid
|
||||||
|
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||||
|
if (accessibleBackgroundColor !== null) {
|
||||||
|
return colourToHex({
|
||||||
|
...accessibleBackgroundColor,
|
||||||
|
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [colour, isLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
id={rowId}
|
id={rowId}
|
||||||
className={cx([
|
className={cx([
|
||||||
style.eventRow,
|
style.eventRow,
|
||||||
event.skip && style.skip,
|
skip && style.skip,
|
||||||
firstAfterGroup && style.firstAfterGroup,
|
isFirstAfterGroup && style.firstAfterGroup,
|
||||||
Boolean(parentBgColour) && style.hasParent,
|
parent && style.hasParent,
|
||||||
])}
|
])}
|
||||||
style={{
|
style={{
|
||||||
opacity: `${isPast ? '0.2' : '1'}`,
|
opacity: `${isPast ? '0.2' : '1'}`,
|
||||||
'--user-bg': parentBgColour ?? 'transparent',
|
'--user-bg': groupColour ?? 'transparent',
|
||||||
}}
|
}}
|
||||||
ref={selectedRef ?? ownRef}
|
|
||||||
data-testid='cuesheet-event'
|
data-testid='cuesheet-event'
|
||||||
|
{...virtuosoProps}
|
||||||
>
|
>
|
||||||
{cuesheetMode === AppMode.Edit && (
|
{cuesheetMode === AppMode.Edit && (
|
||||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||||
@@ -94,7 +96,7 @@ export default function EventRow({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const yPos = 8 + rect.y + rect.height / 2;
|
const yPos = 8 + rect.y + rect.height / 2;
|
||||||
openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent, event.flag);
|
openMenu({ x: rect.x, y: yPos }, id, SupportedEntry.Event, rowIndex, parent, flag);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IoEllipsisHorizontal />
|
<IoEllipsisHorizontal />
|
||||||
@@ -106,26 +108,24 @@ export default function EventRow({
|
|||||||
{eventIndex}
|
{eventIndex}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{isVisible
|
{table
|
||||||
? table
|
.getRow(rowId)
|
||||||
.getRow(rowId)
|
.getVisibleCells()
|
||||||
.getVisibleCells()
|
.map((cell) => {
|
||||||
.map((cell) => {
|
return (
|
||||||
return (
|
<td
|
||||||
<td
|
key={cell.id}
|
||||||
key={cell.id}
|
style={{
|
||||||
style={{
|
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
backgroundColor: rowBgColour,
|
||||||
backgroundColor: rowBgColour,
|
}}
|
||||||
}}
|
tabIndex={-1}
|
||||||
tabIndex={-1}
|
role='cell'
|
||||||
role='cell'
|
>
|
||||||
>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
</td>
|
||||||
</td>
|
);
|
||||||
);
|
})}
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
@import '../CuesheetTable.module.scss';
|
@import '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
.groupRow {
|
.groupRow {
|
||||||
margin-top: 1rem;
|
margin-top: 2em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import { useCurrentGroupId } from '../../../../common/hooks/useSocket';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
|
|
||||||
@@ -12,15 +12,12 @@ import style from './GroupRow.module.scss';
|
|||||||
interface GroupRowProps {
|
interface GroupRowProps {
|
||||||
groupId: EntryId;
|
groupId: EntryId;
|
||||||
colour: string;
|
colour: string;
|
||||||
hidePast: boolean;
|
|
||||||
rowId: string;
|
rowId: string;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: Table<OntimeEntry>;
|
table: Table<ExtendedEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, table }: GroupRowProps) {
|
export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) {
|
||||||
const { currentGroupId } = useCurrentGroupId();
|
|
||||||
|
|
||||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||||
cuesheetMode: AppMode.Edit,
|
cuesheetMode: AppMode.Edit,
|
||||||
hideIndexColumn: false,
|
hideIndexColumn: false,
|
||||||
@@ -28,12 +25,8 @@ export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, t
|
|||||||
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
if (hidePast && !currentGroupId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group'>
|
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group' {...virtuosoProps}>
|
||||||
{cuesheetMode === AppMode.Edit && (
|
{cuesheetMode === AppMode.Edit && (
|
||||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
@import "../CuesheetTable.module.scss";
|
@import "../CuesheetTable.module.scss";
|
||||||
|
|
||||||
.milestoneRow {
|
.milestoneRow {
|
||||||
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
|
background: color-mix(in srgb, transparent 98%, var(--user-bg, $gray-500) 2%);
|
||||||
border-left: 4px solid var(--user-bg, $gray-500);
|
border-left: 4px solid var(--user-bg, $gray-500);
|
||||||
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
+23
-7
@@ -1,9 +1,11 @@
|
|||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import { cx, enDash } from '../../../../common/utils/styleUtils';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
|
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
|
|
||||||
@@ -12,12 +14,12 @@ import style from './MilestoneRow.module.scss';
|
|||||||
interface MilestoneRowProps {
|
interface MilestoneRowProps {
|
||||||
entryId: EntryId;
|
entryId: EntryId;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
parentBgColour: string | null;
|
parentBgColour?: string;
|
||||||
parentId: EntryId | null;
|
parentId: EntryId | null;
|
||||||
rowBgColour?: string;
|
colour: string;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: Table<OntimeEntry>;
|
table: Table<ExtendedEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MilestoneRow({
|
export default function MilestoneRow({
|
||||||
@@ -25,10 +27,11 @@ export default function MilestoneRow({
|
|||||||
isPast,
|
isPast,
|
||||||
parentBgColour,
|
parentBgColour,
|
||||||
parentId,
|
parentId,
|
||||||
rowBgColour,
|
colour,
|
||||||
rowId,
|
rowId,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
table,
|
table,
|
||||||
|
...virtuosoProps
|
||||||
}: MilestoneRowProps) {
|
}: MilestoneRowProps) {
|
||||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||||
cuesheetMode: AppMode.Edit,
|
cuesheetMode: AppMode.Edit,
|
||||||
@@ -37,6 +40,18 @@ export default function MilestoneRow({
|
|||||||
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
|
let rowBgColour: string | undefined;
|
||||||
|
if (colour) {
|
||||||
|
// the colour is user defined and might be invalid
|
||||||
|
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||||
|
if (accessibleBackgroundColor !== null) {
|
||||||
|
rowBgColour = colourToHex({
|
||||||
|
...accessibleBackgroundColor,
|
||||||
|
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
|
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
|
||||||
@@ -45,6 +60,7 @@ export default function MilestoneRow({
|
|||||||
'--user-bg': parentBgColour ?? 'transparent',
|
'--user-bg': parentBgColour ?? 'transparent',
|
||||||
}}
|
}}
|
||||||
data-testid='cuesheet-milestone'
|
data-testid='cuesheet-milestone'
|
||||||
|
{...virtuosoProps}
|
||||||
>
|
>
|
||||||
{cuesheetMode === AppMode.Edit && (
|
{cuesheetMode === AppMode.Edit && (
|
||||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||||
@@ -79,9 +95,9 @@ export default function MilestoneRow({
|
|||||||
style={{
|
style={{
|
||||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||||
backgroundColor: rowBgColour,
|
backgroundColor: rowBgColour,
|
||||||
|
opacity: canRender ? 1 : 0.4,
|
||||||
}}
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
role='cell'
|
|
||||||
>
|
>
|
||||||
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+6
-7
@@ -2,12 +2,13 @@ import { CSSProperties, ReactNode } from 'react';
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Header } from '@tanstack/react-table';
|
import { Header } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface SortableCellProps {
|
interface SortableCellProps {
|
||||||
header: Header<OntimeEntry, unknown>;
|
header: Header<ExtendedEntry, unknown>;
|
||||||
injectedStyles: CSSProperties;
|
injectedStyles: CSSProperties;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -34,11 +35,9 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
{...{
|
onDoubleClick={() => header.column.resetSize()}
|
||||||
onDoubleClick: () => header.column.resetSize(),
|
onMouseDown={header.getResizeHandler()}
|
||||||
onMouseDown: header.getResizeHandler(),
|
onTouchStart={header.getResizeHandler()}
|
||||||
onTouchStart: header.getResizeHandler(),
|
|
||||||
}}
|
|
||||||
className={style.resizer}
|
className={style.resizer}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
+16
-15
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy, URLPreset } from 'ontime-types';
|
import { CustomFields, isOntimeDelay, isOntimeEvent, TimeStrategy, URLPreset } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ import MutedText from './MutedText';
|
|||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||||
if (!table.options.meta) {
|
if (!table.options.meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, un
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||||
if (!table.options.meta) {
|
if (!table.options.meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unkn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||||
if (!table.options.meta) {
|
if (!table.options.meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -126,7 +127,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||||
@@ -135,8 +136,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
|
|||||||
);
|
);
|
||||||
|
|
||||||
// not all entries have all properties (eg groups)
|
// not all entries have all properties (eg groups)
|
||||||
const initialValue = row.original[column.id as keyof OntimeEntry];
|
const initialValue = row.original[column.id as keyof ExtendedEntry];
|
||||||
if (initialValue === undefined) {
|
if (typeof initialValue !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
|
|||||||
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
@@ -166,7 +167,7 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
|||||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||||
@@ -175,8 +176,8 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
|
|||||||
);
|
);
|
||||||
|
|
||||||
// not all entries have all properties (eg groups)
|
// not all entries have all properties (eg groups)
|
||||||
const initialValue = row.original[column.id as keyof OntimeEntry];
|
const initialValue = row.original[column.id as keyof ExtendedEntry];
|
||||||
if (initialValue === undefined) {
|
if (typeof initialValue !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
|
|||||||
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
|
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const event = row.original;
|
const event = row.original;
|
||||||
if (!isOntimeEvent(event) || !event.flag) {
|
if (!isOntimeEvent(event) || !event.flag) {
|
||||||
return null;
|
return null;
|
||||||
@@ -196,7 +197,7 @@ function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
|
|||||||
return <FlagCell />;
|
return <FlagCell />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
@@ -229,8 +230,8 @@ export function makeCuesheetColumns(
|
|||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
cuesheetMode: AppMode,
|
cuesheetMode: AppMode,
|
||||||
preset: URLPreset | undefined,
|
preset: URLPreset | undefined,
|
||||||
): ColumnDef<OntimeEntry>[] {
|
): ColumnDef<ExtendedEntry>[] {
|
||||||
const columnsDef: ColumnDef<OntimeEntry>[] = [];
|
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||||
const fullRead = preset ? preset.options?.read === 'full' : true;
|
const fullRead = preset ? preset.options?.read === 'full' : true;
|
||||||
const fullWrite = preset ? preset.options?.write === 'full' : true;
|
const fullWrite = preset ? preset.options?.write === 'full' : true;
|
||||||
|
|||||||
+2
-9
@@ -6,13 +6,13 @@ import { ToggleGroup } from '@base-ui-components/react/toggle-group';
|
|||||||
import { Toolbar } from '@base-ui-components/react/toolbar';
|
import { Toolbar } from '@base-ui-components/react/toolbar';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
import type { Column } from '@tanstack/react-table';
|
import type { Column } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||||
import { PresetContext } from '../../../../common/context/PresetContext';
|
import { PresetContext } from '../../../../common/context/PresetContext';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
|
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
@@ -23,7 +23,7 @@ import CuesheetShareModal from './CuesheetShareModal';
|
|||||||
import style from './CuesheetTableSettings.module.scss';
|
import style from './CuesheetTableSettings.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableSettingsProps {
|
interface CuesheetTableSettingsProps {
|
||||||
columns: Column<OntimeEntry, unknown>[];
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
handleResetResizing: () => void;
|
handleResetResizing: () => void;
|
||||||
handleResetReordering: () => void;
|
handleResetReordering: () => void;
|
||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
@@ -106,13 +106,6 @@ function ViewSettings() {
|
|||||||
/>
|
/>
|
||||||
Hide seconds in table
|
Hide seconds in table
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
<Editor.Label className={style.option}>
|
|
||||||
<Checkbox
|
|
||||||
defaultChecked={options.hidePast}
|
|
||||||
onCheckedChange={(checked) => options.setOption('hidePast', checked)}
|
|
||||||
/>
|
|
||||||
Hide past events
|
|
||||||
</Editor.Label>
|
|
||||||
<Editor.Label className={style.option}>
|
<Editor.Label className={style.option}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={options.hideIndexColumn}
|
defaultChecked={options.hideIndexColumn}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import { debounce } from '../../../common/utils/debounce';
|
import { debounce } from '../../../common/utils/debounce';
|
||||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||||
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
|
|
||||||
const tableSizesKey = makeStageKey('cuesheet-sizes');
|
const tableSizesKey = makeStageKey('cuesheet-sizes');
|
||||||
const tableHiddenKey = makeStageKey('cuesheet-hidden');
|
const tableHiddenKey = makeStageKey('cuesheet-hidden');
|
||||||
@@ -14,7 +14,7 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
|
|||||||
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
|
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
|
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({
|
const [columnVisibility, setColumnVisibility] = useLocalStorage({
|
||||||
key: tableHiddenKey,
|
key: tableHiddenKey,
|
||||||
defaultValue: {},
|
defaultValue: {},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { persist } from 'zustand/middleware';
|
|||||||
|
|
||||||
type OptionValues = {
|
type OptionValues = {
|
||||||
hideTableSeconds: boolean;
|
hideTableSeconds: boolean;
|
||||||
hidePast: boolean;
|
|
||||||
hideIndexColumn: boolean;
|
hideIndexColumn: boolean;
|
||||||
showDelayedTimes: boolean;
|
showDelayedTimes: boolean;
|
||||||
hideDelays: boolean;
|
hideDelays: boolean;
|
||||||
@@ -12,7 +11,6 @@ type OptionValues = {
|
|||||||
|
|
||||||
const defaultOptions: OptionValues = {
|
const defaultOptions: OptionValues = {
|
||||||
hideTableSeconds: false,
|
hideTableSeconds: false,
|
||||||
hidePast: false,
|
|
||||||
hideIndexColumn: false,
|
hideIndexColumn: false,
|
||||||
showDelayedTimes: false,
|
showDelayedTimes: false,
|
||||||
hideDelays: false,
|
hideDelays: false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.0.0-alpha.4",
|
"version": "4.0.0-alpha.5",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.0.0-alpha.4",
|
"version": "4.0.0-alpha.5",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
||||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
||||||
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
||||||
"prebuild": "tsx ./scripts/bundleCss.ts",
|
"prebuild": "tsx ./scripts/bundleCss.ts && tsx ./scripts/bundleTranslation.ts",
|
||||||
"build": "node esbuild.electron.js",
|
"build": "node esbuild.electron.js",
|
||||||
"build:electron": "node esbuild.electron.js",
|
"build:electron": "node esbuild.electron.js",
|
||||||
"build:local": "node esbuild.dev.js",
|
"build:local": "node esbuild.dev.js",
|
||||||
|
|||||||
@@ -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();
|
this.lastConnection = new Date();
|
||||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
|
||||||
|
|
||||||
sendPacket(MessageTag.ClientInit, { clientId, clientName });
|
sendPacket(MessageTag.ClientInit, { clientId, clientName });
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
|
||||||
this.sendClientList();
|
this.sendClientList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { ErrorResponse } from 'ontime-types';
|
import { RefetchKey, type ErrorResponse } from 'ontime-types';
|
||||||
import { validatePostCss } from './assets.validation.js';
|
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
|
||||||
import { readCssFile, writeCssFile } from './assets.service.js';
|
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||||
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -38,3 +39,21 @@ router.post('/css/restore', async (_req: Request, res: Response<string | ErrorRe
|
|||||||
res.status(500).send({ message });
|
res.status(500).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/translations', validatePostTranslation, async (req: Request, res: Response<never | ErrorResponse>) => {
|
||||||
|
const { translation } = req.body;
|
||||||
|
|
||||||
|
if (!translation) {
|
||||||
|
res.status(400).send({ message: 'translation payload is required ' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeUserTranslation(translation);
|
||||||
|
sendRefetch(RefetchKey.Translation);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(500).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { publicFiles } from '../../setup/index.js';
|
import type { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { publicFiles } from '../../setup/index.js';
|
||||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,3 +34,13 @@ export async function writeCssFile(css: string) {
|
|||||||
|
|
||||||
await writeFile(path, css, { encoding: 'utf8' });
|
await writeFile(path, css, { encoding: 'utf8' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the user's custom translation file
|
||||||
|
* @param translations the updated translations to write to file
|
||||||
|
*/
|
||||||
|
export async function writeUserTranslation(translations: TranslationObject) {
|
||||||
|
const path = publicFiles.translationsFile;
|
||||||
|
const translationsString = JSON.stringify(translations, null, 2);
|
||||||
|
await writeFile(path, translationsString, { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { body } from 'express-validator';
|
import { body } from 'express-validator';
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
|
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
|
||||||
|
|
||||||
|
export const validatePostTranslation = [
|
||||||
|
body('translation')
|
||||||
|
.custom((v) => v != null && typeof v === 'object' && !Array.isArray(v))
|
||||||
|
.withMessage('translation must be an object (key -> string)')
|
||||||
|
.bail(),
|
||||||
|
body('translation.*').isString().trim().notEmpty(),
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { getErrorMessage } from 'ontime-utils';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||||
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
|
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
|
||||||
|
|
||||||
@@ -11,37 +13,52 @@ import { validateCustomField, validateDeleteCustomField, validateEditCustomField
|
|||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all the custom fields for the project
|
||||||
|
*/
|
||||||
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
|
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
|
||||||
const customFields = getProjectCustomFields();
|
const customFields = getProjectCustomFields();
|
||||||
res.status(200).json(customFields);
|
res.status(200).json(customFields);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new custom field
|
||||||
|
*/
|
||||||
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const newFields = await createCustomField(req.body as CustomField);
|
const newFields = await createCustomField(req.body as CustomField);
|
||||||
res.status(201).send(newFields);
|
res.status(201).json(newFields);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifies the properties of an existing custom field
|
||||||
|
*/
|
||||||
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const currentKey = req.params.key;
|
const currentKey = req.params.key;
|
||||||
const { colour, type, label } = req.body;
|
const { colour, type, label } = req.body;
|
||||||
const newFields = await editCustomField(currentKey, { label, colour, type });
|
|
||||||
res.status(200).send(newFields);
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
|
const newFields = await editCustomField(currentKey, { label, colour, type }, projectRundowns);
|
||||||
|
res.status(200).json(newFields);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an existing custom field
|
||||||
|
*/
|
||||||
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const customFields = await deleteCustomField(req.params.key);
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
res.status(200).send(customFields);
|
const customFields = await deleteCustomField(req.params.key, projectRundowns);
|
||||||
|
res.status(200).json(customFields);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
|||||||
errors: ParsingError[];
|
errors: ParsingError[];
|
||||||
migrated: boolean;
|
migrated: boolean;
|
||||||
} {
|
} {
|
||||||
//TODO: TEST THIS!!!!!!!
|
|
||||||
let migrated = false;
|
let migrated = false;
|
||||||
let migratedData = jsonData;
|
let migratedData = jsonData;
|
||||||
if (v3.shouldUseThisMigration(jsonData)) {
|
if (v3.shouldUseThisMigration(jsonData)) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
import { CustomFields, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
|
||||||
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { getCustomFieldData, parseExcel } from '../excel.parser.js';
|
import { parseExcel } from '../excel.parser.js';
|
||||||
|
|
||||||
import { dataFromExcelTemplate } from './mockData.js';
|
import { dataFromExcelTemplate } from './mockData.js';
|
||||||
|
|
||||||
@@ -156,11 +156,38 @@ describe('parseExcel()', () => {
|
|||||||
expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth');
|
expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports groups', () => {
|
it('imports group', () => {
|
||||||
|
const testdata = [
|
||||||
|
['Title', 'Timer type', 'duration'],
|
||||||
|
['a group', 'group', '10m'],
|
||||||
|
['an event', 'clock', '1m'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const importMap = {
|
||||||
|
title: 'title',
|
||||||
|
timerType: 'timer type',
|
||||||
|
duration: 'duration',
|
||||||
|
} as ImportMap;
|
||||||
|
|
||||||
|
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
||||||
|
const firstGroup = result.rundown.entries[result.rundown.order[0]];
|
||||||
|
|
||||||
|
expect(result.rundown.order.length).toBe(1);
|
||||||
|
expect(result.rundown.flatOrder.length).toBe(2);
|
||||||
|
expect((firstGroup as OntimeGroup).type).toBe(SupportedEntry.Group);
|
||||||
|
expect((firstGroup as OntimeGroup).targetDuration).toBe(10 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places event between groups inside the group', () => {
|
||||||
const testdata = [
|
const testdata = [
|
||||||
['Title', 'Timer type'],
|
['Title', 'Timer type'],
|
||||||
['a group', 'group'],
|
['a group', 'group'],
|
||||||
['an event', 'clock'],
|
['an event', 'clock'],
|
||||||
|
['an event', 'clock'],
|
||||||
|
['an event', 'clock'],
|
||||||
|
['a second group ', 'group'],
|
||||||
|
['an event', 'clock'],
|
||||||
|
['an event', 'clock'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const importMap = {
|
const importMap = {
|
||||||
@@ -168,10 +195,17 @@ describe('parseExcel()', () => {
|
|||||||
timerType: 'timer type',
|
timerType: 'timer type',
|
||||||
};
|
};
|
||||||
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
||||||
const firstEvent = result.rundown.entries[result.rundown.order[0]];
|
const firstGroup = result.rundown.entries[result.rundown.order[0]] as OntimeGroup;
|
||||||
|
const secondGroup = result.rundown.entries[result.rundown.order[1]] as OntimeGroup;
|
||||||
|
|
||||||
expect(result.rundown.order.length).toBe(2);
|
expect(result.rundown.order.length).toBe(2);
|
||||||
expect((firstEvent as OntimeEvent).type).toBe(SupportedEntry.Group);
|
expect(result.rundown.flatOrder.length).toBe(7);
|
||||||
|
|
||||||
|
expect(firstGroup.type).toBe(SupportedEntry.Group);
|
||||||
|
expect(firstGroup.entries.length).toBe(3);
|
||||||
|
|
||||||
|
expect(secondGroup.type).toBe(SupportedEntry.Group);
|
||||||
|
expect(secondGroup.entries.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports as events if there is no timer type column', () => {
|
it('imports as events if there is no timer type column', () => {
|
||||||
@@ -314,8 +348,10 @@ describe('parseExcel()', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseExcel(testData, {}, 'testSheet', importMap);
|
const result = parseExcel(testData, {}, 'testSheet', importMap);
|
||||||
expect(result.rundown.order.length).toBe(6);
|
expect(result.rundown.order.length).toBe(5);
|
||||||
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']);
|
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP']);
|
||||||
|
expect(result.rundown.flatOrder.length).toBe(6);
|
||||||
|
expect(result.rundown.flatOrder).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']);
|
||||||
|
|
||||||
expect(result.rundown.entries).toMatchObject({
|
expect(result.rundown.entries).toMatchObject({
|
||||||
A: {
|
A: {
|
||||||
@@ -417,162 +453,41 @@ describe('parseExcel()', () => {
|
|||||||
linkStart: true,
|
linkStart: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCustomFieldData()', () => {
|
it('handles milestones', () => {
|
||||||
it('generates a list of keys from the given import map', () => {
|
const testdata = [
|
||||||
|
['Title', 'type', 'notes'],
|
||||||
|
['event...', 'count-down', ''],
|
||||||
|
['also event...', 'count-down', ''],
|
||||||
|
['this i a milestone', 'milestone', 'milestone note'],
|
||||||
|
];
|
||||||
|
|
||||||
const importMap = {
|
const importMap = {
|
||||||
worksheet: 'event schedule',
|
|
||||||
timeStart: 'time start',
|
|
||||||
linkStart: 'link start',
|
|
||||||
timeEnd: 'time end',
|
|
||||||
duration: 'duration',
|
|
||||||
flag: 'flag',
|
|
||||||
cue: 'cue',
|
|
||||||
title: 'title',
|
title: 'title',
|
||||||
countToEnd: 'count to end',
|
timerType: 'type',
|
||||||
skip: 'skip',
|
|
||||||
note: 'notes',
|
note: 'notes',
|
||||||
colour: 'colour',
|
|
||||||
endAction: 'end action',
|
|
||||||
timerType: 'timer type',
|
|
||||||
timeWarning: 'warning time',
|
|
||||||
timeDanger: 'danger time',
|
|
||||||
custom: {
|
|
||||||
lighting: 'lx',
|
|
||||||
sound: 'sound',
|
|
||||||
video: 'av',
|
|
||||||
},
|
|
||||||
entryId: 'id',
|
|
||||||
} as ImportMap;
|
} as ImportMap;
|
||||||
|
|
||||||
const result = getCustomFieldData(importMap, {});
|
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
||||||
expect(result.mergedCustomFields).toStrictEqual({
|
const firstEvent = result.rundown.entries[result.rundown.order[0]];
|
||||||
lighting: {
|
const secondEvent = result.rundown.entries[result.rundown.order[1]];
|
||||||
type: 'text',
|
const milestone = result.rundown.entries[result.rundown.order[2]];
|
||||||
colour: '',
|
|
||||||
label: 'lighting',
|
expect(result.rundown.order.length).toBe(3);
|
||||||
},
|
expect(firstEvent).toMatchObject({
|
||||||
sound: {
|
type: SupportedEntry.Event,
|
||||||
type: 'text',
|
timerType: TimerType.CountDown,
|
||||||
colour: '',
|
|
||||||
label: 'sound',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
type: 'text',
|
|
||||||
colour: '',
|
|
||||||
label: 'video',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// it is an inverted record of <importKey, ontimeKey>
|
expect(secondEvent).toMatchObject({
|
||||||
expect(result.customFieldImportKeys).toStrictEqual({
|
type: SupportedEntry.Event,
|
||||||
lx: 'lighting',
|
timerType: TimerType.CountDown,
|
||||||
sound: 'sound',
|
|
||||||
av: 'video',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps colour information from existing fields', () => {
|
|
||||||
const importMap = {
|
|
||||||
worksheet: 'event schedule',
|
|
||||||
timeStart: 'time start',
|
|
||||||
linkStart: 'link start',
|
|
||||||
timeEnd: 'time end',
|
|
||||||
duration: 'duration',
|
|
||||||
flag: 'flag',
|
|
||||||
cue: 'cue',
|
|
||||||
title: 'title',
|
|
||||||
countToEnd: 'count to end',
|
|
||||||
skip: 'skip',
|
|
||||||
note: 'notes',
|
|
||||||
colour: 'colour',
|
|
||||||
endAction: 'end action',
|
|
||||||
timerType: 'timer type',
|
|
||||||
timeWarning: 'warning time',
|
|
||||||
timeDanger: 'danger time',
|
|
||||||
custom: {
|
|
||||||
lighting: 'lx',
|
|
||||||
sound: 'sound',
|
|
||||||
video: 'av',
|
|
||||||
'ontime key': 'excel label',
|
|
||||||
},
|
|
||||||
entryId: 'id',
|
|
||||||
} as ImportMap;
|
|
||||||
|
|
||||||
const existingCustomFields: CustomFields = {
|
|
||||||
lighting: { label: 'lighting', type: 'text', colour: 'red' },
|
|
||||||
sound: { label: 'sound', type: 'text', colour: 'green' },
|
|
||||||
ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = getCustomFieldData(importMap, existingCustomFields);
|
|
||||||
expect(result.mergedCustomFields).toStrictEqual({
|
|
||||||
lighting: {
|
|
||||||
type: 'text',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'lighting',
|
|
||||||
},
|
|
||||||
sound: {
|
|
||||||
type: 'text',
|
|
||||||
colour: 'green',
|
|
||||||
label: 'sound',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
type: 'text',
|
|
||||||
colour: '',
|
|
||||||
label: 'video',
|
|
||||||
},
|
|
||||||
ontime_key: {
|
|
||||||
type: 'text',
|
|
||||||
colour: 'blue',
|
|
||||||
label: 'ontime key',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// it is an inverted record of <importKey, ontimeKey>
|
expect(milestone).toMatchObject({
|
||||||
expect(result.customFieldImportKeys).toStrictEqual({
|
type: SupportedEntry.Milestone,
|
||||||
lx: 'lighting',
|
title: 'this i a milestone',
|
||||||
sound: 'sound',
|
note: 'milestone note',
|
||||||
av: 'video',
|
|
||||||
'excel label': 'ontime_key',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lowercases the keys in the import map', () => {
|
|
||||||
const importMap: ImportMap = {
|
|
||||||
...defaultImportMap,
|
|
||||||
custom: {
|
|
||||||
Lighting: 'Lx',
|
|
||||||
Sound: 'sound',
|
|
||||||
video: 'av',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = getCustomFieldData(importMap, {});
|
|
||||||
expect(result.mergedCustomFields).toStrictEqual({
|
|
||||||
Lighting: {
|
|
||||||
type: 'text',
|
|
||||||
colour: '',
|
|
||||||
label: 'Lighting',
|
|
||||||
},
|
|
||||||
Sound: {
|
|
||||||
type: 'text',
|
|
||||||
colour: '',
|
|
||||||
label: 'Sound',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
type: 'text',
|
|
||||||
colour: '',
|
|
||||||
label: 'video',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// notice that the keys excel keys are lowercased
|
|
||||||
expect(result.customFieldImportKeys).toStrictEqual({
|
|
||||||
lx: 'Lighting',
|
|
||||||
sound: 'Sound',
|
|
||||||
av: 'video',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { getCustomFieldData } from '../excel.utils.js';
|
||||||
|
|
||||||
|
describe('getCustomFieldData()', () => {
|
||||||
|
it('generates a list of keys from the given import map', () => {
|
||||||
|
const importMap = {
|
||||||
|
worksheet: 'event schedule',
|
||||||
|
timeStart: 'time start',
|
||||||
|
linkStart: 'link start',
|
||||||
|
timeEnd: 'time end',
|
||||||
|
duration: 'duration',
|
||||||
|
flag: 'flag',
|
||||||
|
cue: 'cue',
|
||||||
|
title: 'title',
|
||||||
|
countToEnd: 'count to end',
|
||||||
|
skip: 'skip',
|
||||||
|
note: 'notes',
|
||||||
|
colour: 'colour',
|
||||||
|
endAction: 'end action',
|
||||||
|
timerType: 'timer type',
|
||||||
|
timeWarning: 'warning time',
|
||||||
|
timeDanger: 'danger time',
|
||||||
|
custom: {
|
||||||
|
lighting: 'lx',
|
||||||
|
sound: 'sound',
|
||||||
|
video: 'av',
|
||||||
|
},
|
||||||
|
entryId: 'id',
|
||||||
|
} as ImportMap;
|
||||||
|
|
||||||
|
const result = getCustomFieldData(importMap, {});
|
||||||
|
expect(result.mergedCustomFields).toStrictEqual({
|
||||||
|
lighting: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'sound',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'video',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// it is an inverted record of <importKey, ontimeKey>
|
||||||
|
expect(result.customFieldImportKeys).toStrictEqual({
|
||||||
|
lx: 'lighting',
|
||||||
|
sound: 'sound',
|
||||||
|
av: 'video',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps colour information from existing fields', () => {
|
||||||
|
const importMap = {
|
||||||
|
worksheet: 'event schedule',
|
||||||
|
timeStart: 'time start',
|
||||||
|
linkStart: 'link start',
|
||||||
|
timeEnd: 'time end',
|
||||||
|
duration: 'duration',
|
||||||
|
flag: 'flag',
|
||||||
|
cue: 'cue',
|
||||||
|
title: 'title',
|
||||||
|
countToEnd: 'count to end',
|
||||||
|
skip: 'skip',
|
||||||
|
note: 'notes',
|
||||||
|
colour: 'colour',
|
||||||
|
endAction: 'end action',
|
||||||
|
timerType: 'timer type',
|
||||||
|
timeWarning: 'warning time',
|
||||||
|
timeDanger: 'danger time',
|
||||||
|
custom: {
|
||||||
|
lighting: 'lx',
|
||||||
|
sound: 'sound',
|
||||||
|
video: 'av',
|
||||||
|
'ontime key': 'excel label',
|
||||||
|
},
|
||||||
|
entryId: 'id',
|
||||||
|
} as ImportMap;
|
||||||
|
|
||||||
|
const existingCustomFields: CustomFields = {
|
||||||
|
lighting: { label: 'lighting', type: 'text', colour: 'red' },
|
||||||
|
sound: { label: 'sound', type: 'text', colour: 'green' },
|
||||||
|
ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getCustomFieldData(importMap, existingCustomFields);
|
||||||
|
expect(result.mergedCustomFields).toStrictEqual({
|
||||||
|
lighting: {
|
||||||
|
type: 'text',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
type: 'text',
|
||||||
|
colour: 'green',
|
||||||
|
label: 'sound',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'video',
|
||||||
|
},
|
||||||
|
ontime_key: {
|
||||||
|
type: 'text',
|
||||||
|
colour: 'blue',
|
||||||
|
label: 'ontime key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// it is an inverted record of <importKey, ontimeKey>
|
||||||
|
expect(result.customFieldImportKeys).toStrictEqual({
|
||||||
|
lx: 'lighting',
|
||||||
|
sound: 'sound',
|
||||||
|
av: 'video',
|
||||||
|
'excel label': 'ontime_key',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases the keys in the import map', () => {
|
||||||
|
const importMap: ImportMap = {
|
||||||
|
...defaultImportMap,
|
||||||
|
custom: {
|
||||||
|
Lighting: 'Lx',
|
||||||
|
Sound: 'sound',
|
||||||
|
video: 'av',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getCustomFieldData(importMap, {});
|
||||||
|
expect(result.mergedCustomFields).toStrictEqual({
|
||||||
|
Lighting: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'Lighting',
|
||||||
|
},
|
||||||
|
Sound: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'Sound',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '',
|
||||||
|
label: 'video',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// notice that the keys excel keys are lowercased
|
||||||
|
expect(result.customFieldImportKeys).toStrictEqual({
|
||||||
|
lx: 'Lighting',
|
||||||
|
sound: 'Sound',
|
||||||
|
av: 'video',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
TimerType,
|
TimerType,
|
||||||
CustomFieldKey,
|
OntimeMilestone,
|
||||||
|
OntimeEntry,
|
||||||
|
isOntimeMilestone,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
ImportMap,
|
ImportMap,
|
||||||
@@ -16,22 +18,26 @@ import {
|
|||||||
isKnownTimerType,
|
isKnownTimerType,
|
||||||
validateTimerType,
|
validateTimerType,
|
||||||
validateEndAction,
|
validateEndAction,
|
||||||
customFieldLabelToKey,
|
|
||||||
checkRegex,
|
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { Merge } from 'ts-essentials';
|
import { Prettify } from 'ts-essentials';
|
||||||
|
|
||||||
import { is } from '../../utils/is.js';
|
import { is } from '../../utils/is.js';
|
||||||
import { makeString } from '../../utils/parserUtils.js';
|
import { makeString } from '../../utils/parserUtils.js';
|
||||||
import { parseExcelDate } from '../../utils/time.js';
|
import { parseExcelDate } from '../../utils/time.js';
|
||||||
|
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
|
||||||
|
|
||||||
|
type MergedOntimeEntry = Prettify<
|
||||||
|
Omit<Omit<Omit<OntimeEvent, keyof OntimeGroup> & OntimeGroup, keyof OntimeMilestone> & OntimeMilestone, 'type'> & {
|
||||||
|
type: SupportedEntry | 'group-end';
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Excel array parser
|
* @description Excel array parser
|
||||||
* @param {array} excelData - array with excel sheet
|
* @param {array} excelData - array with excel sheet
|
||||||
* @param {ImportOptions} options - an object that contains the import map
|
* @param {ImportOptions} options - an object that contains the import map
|
||||||
* @returns {object} - parsed object
|
* @returns {object} - parsed object
|
||||||
* TODO: import milestones
|
|
||||||
*/
|
*/
|
||||||
export const parseExcel = (
|
export const parseExcel = (
|
||||||
excelData: unknown[][],
|
excelData: unknown[][],
|
||||||
@@ -41,9 +47,8 @@ export const parseExcel = (
|
|||||||
): {
|
): {
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
rundownMetadata: Record<string, { row: number; col: number }>;
|
sheetMetadata: SheetMetadata;
|
||||||
} => {
|
} => {
|
||||||
const rundownMetadata: Record<string, { row: number; col: number }> = {};
|
|
||||||
const importMap: ImportMap = { ...defaultImportMap, ...options };
|
const importMap: ImportMap = { ...defaultImportMap, ...options };
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(importMap)) {
|
for (const [key, value] of Object.entries(importMap)) {
|
||||||
@@ -63,166 +68,74 @@ export const parseExcel = (
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// title stuff: strings
|
// for placing entries into groups
|
||||||
let titleIndex: number | null = null;
|
let currentGroupId: string | null = null;
|
||||||
let cueIndex: number | null = null;
|
const groupEntries: string[] = [];
|
||||||
let notesIndex: number | null = null;
|
const { handlers, indexMap, sheetMetadata } = generateImportHandlers(importMap);
|
||||||
let colourIndex: number | null = null;
|
|
||||||
|
|
||||||
// options: booleans
|
|
||||||
let flagIndex: number | null = null;
|
|
||||||
let skipIndex: number | null = null;
|
|
||||||
let countToEndIndex: number | null = null;
|
|
||||||
|
|
||||||
let linkStartIndex: number | null = null;
|
|
||||||
|
|
||||||
// times: numbers
|
|
||||||
let timeStartIndex: number | null = null;
|
|
||||||
let timeEndIndex: number | null = null;
|
|
||||||
let durationIndex: number | null = null;
|
|
||||||
let timeWarningIndex: number | null = null;
|
|
||||||
let timeDangerIndex: number | null = null;
|
|
||||||
|
|
||||||
// options: enum properties
|
|
||||||
let endActionIndex: number | null = null;
|
|
||||||
let timerTypeIndex: number | null = null;
|
|
||||||
|
|
||||||
//ID
|
|
||||||
let entryIdIndex: number | null = null;
|
|
||||||
|
|
||||||
// record of column index and the name of the field
|
|
||||||
const customFieldIndexes: Record<number, string> = {};
|
|
||||||
|
|
||||||
excelData.forEach((row, rowIndex) => {
|
excelData.forEach((row, rowIndex) => {
|
||||||
if (row.length === 0) {
|
if (row.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: extract generating handlers from importMap
|
const entry: Partial<MergedOntimeEntry> = {};
|
||||||
const handlers = {
|
|
||||||
[importMap.timeStart]: (row: number, col: number) => {
|
|
||||||
timeStartIndex = col;
|
|
||||||
rundownMetadata['timeStart'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.linkStart]: (row: number, col: number) => {
|
|
||||||
linkStartIndex = col;
|
|
||||||
rundownMetadata['linkStart'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.timeEnd]: (row: number, col: number) => {
|
|
||||||
timeEndIndex = col;
|
|
||||||
rundownMetadata['timeEnd'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.duration]: (row: number, col: number) => {
|
|
||||||
durationIndex = col;
|
|
||||||
rundownMetadata['duration'] = { row, col };
|
|
||||||
},
|
|
||||||
|
|
||||||
[importMap.cue]: (row: number, col: number) => {
|
|
||||||
cueIndex = col;
|
|
||||||
rundownMetadata['cue'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.title]: (row: number, col: number) => {
|
|
||||||
titleIndex = col;
|
|
||||||
rundownMetadata['title'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.flag]: (row: number, col: number) => {
|
|
||||||
flagIndex = col;
|
|
||||||
rundownMetadata['flag'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.countToEnd]: (row: number, col: number) => {
|
|
||||||
countToEndIndex = col;
|
|
||||||
rundownMetadata['countToEnd'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.skip]: (row: number, col: number) => {
|
|
||||||
skipIndex = col;
|
|
||||||
rundownMetadata['skip'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.note]: (row: number, col: number) => {
|
|
||||||
notesIndex = col;
|
|
||||||
rundownMetadata['note'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.colour]: (row: number, col: number) => {
|
|
||||||
colourIndex = col;
|
|
||||||
rundownMetadata['colour'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.endAction]: (row: number, col: number) => {
|
|
||||||
endActionIndex = col;
|
|
||||||
rundownMetadata['endAction'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.timerType]: (row: number, col: number) => {
|
|
||||||
timerTypeIndex = col;
|
|
||||||
rundownMetadata['timerType'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.timeWarning]: (row: number, col: number) => {
|
|
||||||
timeWarningIndex = col;
|
|
||||||
rundownMetadata['timeWarning'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.timeDanger]: (row: number, col: number) => {
|
|
||||||
timeDangerIndex = col;
|
|
||||||
rundownMetadata['timeDanger'] = { row, col };
|
|
||||||
},
|
|
||||||
[importMap.entryId]: (row: number, col: number) => {
|
|
||||||
entryIdIndex = col;
|
|
||||||
rundownMetadata['id'] = { row, col };
|
|
||||||
},
|
|
||||||
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
|
||||||
customFieldIndexes[col] = columnText;
|
|
||||||
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const entry: Partial<Merge<OntimeEvent, OntimeGroup>> = {};
|
|
||||||
const entryCustomFields: EntryCustomFields = {};
|
const entryCustomFields: EntryCustomFields = {};
|
||||||
|
|
||||||
for (let j = 0; j < row.length; j++) {
|
for (let j = 0; j < row.length; j++) {
|
||||||
const column = row[j];
|
const column = row[j];
|
||||||
// 1. we check if we have set a flag for a known field
|
// 1. we check if we have set a flag for a known field
|
||||||
if (j === timerTypeIndex) {
|
if (j === indexMap.timerType) {
|
||||||
const maybeTimeType = makeString(column, '');
|
const maybeTimeType = makeString(column, '').toLowerCase();
|
||||||
if (maybeTimeType === 'group') {
|
if (maybeTimeType === 'group' || maybeTimeType === 'group-start') {
|
||||||
// we leave this as a clue for the object filtering later on
|
|
||||||
entry.type = SupportedEntry.Group;
|
entry.type = SupportedEntry.Group;
|
||||||
entry.entries = [];
|
entry.entries = [];
|
||||||
|
} else if (maybeTimeType === 'group-end') {
|
||||||
|
entry.type = 'group-end';
|
||||||
|
} else if (maybeTimeType === 'milestone') {
|
||||||
|
entry.type = SupportedEntry.Milestone;
|
||||||
|
} else if (maybeTimeType === 'skip-import') {
|
||||||
|
// intentional skip
|
||||||
|
return;
|
||||||
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
|
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
|
||||||
// @ts-expect-error -- we leave this as a clue for the object filtering later on
|
|
||||||
entry.type = SupportedEntry.Event;
|
entry.type = SupportedEntry.Event;
|
||||||
entry.timerType = validateTimerType(maybeTimeType);
|
entry.timerType = validateTimerType(maybeTimeType);
|
||||||
} else {
|
} else {
|
||||||
// if it is not a group or a known type, we dont import it
|
// if it is not a group or a known type, we dont import it
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (j === titleIndex) {
|
} else if (j === indexMap.title) {
|
||||||
entry.title = makeString(column, '');
|
entry.title = makeString(column, '');
|
||||||
} else if (j === timeStartIndex) {
|
} else if (j === indexMap.timeStart) {
|
||||||
entry.timeStart = parseExcelDate(column);
|
entry.timeStart = parseExcelDate(column);
|
||||||
} else if (j === linkStartIndex) {
|
} else if (j === indexMap.linkStart) {
|
||||||
entry.linkStart = parseBooleanString(column);
|
entry.linkStart = parseBooleanString(column);
|
||||||
} else if (j === timeEndIndex) {
|
} else if (j === indexMap.timeEnd) {
|
||||||
entry.timeEnd = parseExcelDate(column);
|
entry.timeEnd = parseExcelDate(column);
|
||||||
} else if (j === durationIndex) {
|
} else if (j === indexMap.duration) {
|
||||||
entry.duration = parseExcelDate(column);
|
entry.duration = parseExcelDate(column);
|
||||||
} else if (j === cueIndex) {
|
} else if (j === indexMap.cue) {
|
||||||
entry.cue = makeString(column, '');
|
entry.cue = makeString(column, '');
|
||||||
} else if (j === flagIndex) {
|
} else if (j === indexMap.flag) {
|
||||||
entry.flag = parseBooleanString(column);
|
entry.flag = parseBooleanString(column);
|
||||||
} else if (j === countToEndIndex) {
|
} else if (j === indexMap.countToEnd) {
|
||||||
entry.countToEnd = parseBooleanString(column);
|
entry.countToEnd = parseBooleanString(column);
|
||||||
} else if (j === skipIndex) {
|
} else if (j === indexMap.skip) {
|
||||||
entry.skip = parseBooleanString(column);
|
entry.skip = parseBooleanString(column);
|
||||||
} else if (j === notesIndex) {
|
} else if (j === indexMap.note) {
|
||||||
entry.note = makeString(column, '');
|
entry.note = makeString(column, '');
|
||||||
} else if (j === endActionIndex) {
|
} else if (j === indexMap.endAction) {
|
||||||
entry.endAction = validateEndAction(column);
|
entry.endAction = validateEndAction(column);
|
||||||
} else if (j === timeWarningIndex) {
|
} else if (j === indexMap.timeWarning) {
|
||||||
entry.timeWarning = parseExcelDate(column);
|
entry.timeWarning = parseExcelDate(column);
|
||||||
} else if (j === timeDangerIndex) {
|
} else if (j === indexMap.timeDanger) {
|
||||||
entry.timeDanger = parseExcelDate(column);
|
entry.timeDanger = parseExcelDate(column);
|
||||||
} else if (j === colourIndex) {
|
} else if (j === indexMap.colour) {
|
||||||
entry.colour = makeString(column, '');
|
entry.colour = makeString(column, '');
|
||||||
} else if (j === entryIdIndex) {
|
} else if (j === indexMap.entryId) {
|
||||||
entry.id = encodeURIComponent(makeString(column, undefined));
|
entry.id = encodeURIComponent(makeString(column, undefined));
|
||||||
} else if (j in customFieldIndexes) {
|
} else if (j in indexMap.custom) {
|
||||||
const importKey = customFieldIndexes[j];
|
const importKey = indexMap.custom[j];
|
||||||
const ontimeKey = customFieldImportKeys[importKey];
|
const ontimeKey = customFieldImportKeys[importKey];
|
||||||
entryCustomFields[ontimeKey] = makeString(column, '');
|
entryCustomFields[ontimeKey] = makeString(column, '');
|
||||||
} else {
|
} else {
|
||||||
@@ -259,92 +172,78 @@ export const parseExcel = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = entry.id || generateId();
|
const id = entry.id || generateId();
|
||||||
// from excel, we can only get groups, milestones and events
|
|
||||||
if (isOntimeGroup(entry)) {
|
if (entry.type === 'group-end') {
|
||||||
const group: OntimeGroup = { ...entry, custom: { ...entryCustomFields } };
|
if (currentGroupId) {
|
||||||
rundown.order.push(id);
|
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
|
||||||
rundown.entries[id] = group;
|
currentGroupId = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// from excel, we can only get groups, milestones and events
|
||||||
|
if (isOntimeGroup(entry as OntimeEntry)) {
|
||||||
|
const group = {
|
||||||
|
...entry,
|
||||||
|
targetDuration: entry.duration ? entry.duration : null,
|
||||||
|
custom: { ...entryCustomFields },
|
||||||
|
} as OntimeGroup;
|
||||||
|
|
||||||
|
rundown.entries[id] = group;
|
||||||
|
if (currentGroupId) {
|
||||||
|
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
|
||||||
|
}
|
||||||
|
rundown.order.push(id);
|
||||||
|
rundown.flatOrder.push(id);
|
||||||
|
currentGroupId = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeMilestone(entry as OntimeEntry)) {
|
||||||
|
const milestone = { ...entry, custom: { ...entryCustomFields } } as OntimeMilestone;
|
||||||
|
if (currentGroupId) {
|
||||||
|
groupEntries.push(id);
|
||||||
|
milestone.parent = currentGroupId;
|
||||||
|
} else {
|
||||||
|
rundown.order.push(id);
|
||||||
|
}
|
||||||
|
rundown.flatOrder.push(id);
|
||||||
|
rundown.entries[id] = milestone;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//and fall through to treat it as an event
|
||||||
const event = {
|
const event = {
|
||||||
...entry,
|
...entry,
|
||||||
custom: { ...entryCustomFields },
|
custom: { ...entryCustomFields },
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
} as OntimeEvent;
|
} as OntimeEvent;
|
||||||
|
|
||||||
if (timerTypeIndex === null) {
|
if (indexMap.timerType === null) {
|
||||||
event.timerType = TimerType.CountDown;
|
event.timerType = TimerType.CountDown;
|
||||||
}
|
}
|
||||||
rundown.order.push(id);
|
|
||||||
|
if (entry.linkStart === undefined) {
|
||||||
|
event.linkStart = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGroupId) {
|
||||||
|
groupEntries.push(id);
|
||||||
|
event.parent = currentGroupId;
|
||||||
|
} else {
|
||||||
|
rundown.order.push(id);
|
||||||
|
}
|
||||||
rundown.flatOrder.push(id);
|
rundown.flatOrder.push(id);
|
||||||
rundown.entries[id] = event;
|
rundown.entries[id] = event;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (currentGroupId) {
|
||||||
|
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rundown,
|
rundown,
|
||||||
customFields: mergedCustomFields,
|
customFields: mergedCustomFields,
|
||||||
rundownMetadata,
|
sheetMetadata,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function infers a boolean from a string value
|
|
||||||
*/
|
|
||||||
function parseBooleanString(value: unknown): boolean {
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// falsy values would be nullish or empty string
|
|
||||||
if (!value || typeof value !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return value.toLowerCase() !== 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives an import map which contains custom field labels and a custom fields object
|
|
||||||
* the result importkeys is an inverted record of <importKey, ontimeKey>
|
|
||||||
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
|
|
||||||
* @returns the new custom fields, and a map of excel column names to ontime keys
|
|
||||||
* @private exported for testing
|
|
||||||
*/
|
|
||||||
export function getCustomFieldData(
|
|
||||||
importMap: ImportMap,
|
|
||||||
existingCustomFields: CustomFields,
|
|
||||||
): {
|
|
||||||
mergedCustomFields: CustomFields;
|
|
||||||
customFieldImportKeys: Record<keyof CustomFields, string>;
|
|
||||||
} {
|
|
||||||
const mergedCustomFields: CustomFields = {};
|
|
||||||
/**
|
|
||||||
* A map of import keys to ontime keys
|
|
||||||
* Map<excel column name, ontime key>
|
|
||||||
*/
|
|
||||||
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
|
|
||||||
|
|
||||||
for (const ontimeLabel in importMap.custom) {
|
|
||||||
// if the label is not valid, we skip the import
|
|
||||||
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate a key for the custom field
|
|
||||||
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
|
|
||||||
// we lower case the excel key to make it easier to match
|
|
||||||
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
|
||||||
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
|
||||||
|
|
||||||
// 1. add the custom field to the merged custom fields
|
|
||||||
mergedCustomFields[keyInCustomFields] = {
|
|
||||||
type: 'text', // we currently only support text custom fields
|
|
||||||
colour: maybeExistingColour,
|
|
||||||
label: ontimeLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. add the column to the import keys
|
|
||||||
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
|
|
||||||
}
|
|
||||||
return { mergedCustomFields, customFieldImportKeys };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types';
|
||||||
|
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives an import map which contains custom field labels and a custom fields object
|
||||||
|
* the result importkeys is an inverted record of <importKey, ontimeKey>
|
||||||
|
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
|
||||||
|
* @returns the new custom fields, and a map of excel column names to ontime keys
|
||||||
|
* @private exported for testing
|
||||||
|
*/
|
||||||
|
export function getCustomFieldData(
|
||||||
|
importMap: ImportMap,
|
||||||
|
existingCustomFields: CustomFields,
|
||||||
|
): {
|
||||||
|
mergedCustomFields: CustomFields;
|
||||||
|
customFieldImportKeys: Record<keyof CustomFields, string>;
|
||||||
|
} {
|
||||||
|
const mergedCustomFields: CustomFields = {};
|
||||||
|
/**
|
||||||
|
* A map of import keys to ontime keys
|
||||||
|
* Map<excel column name, ontime key>
|
||||||
|
*/
|
||||||
|
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
|
||||||
|
|
||||||
|
for (const ontimeLabel in importMap.custom) {
|
||||||
|
// if the label is not valid, we skip the import
|
||||||
|
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate a key for the custom field
|
||||||
|
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
|
||||||
|
// we lower case the excel key to make it easier to match
|
||||||
|
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
||||||
|
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
||||||
|
|
||||||
|
// 1. add the custom field to the merged custom fields
|
||||||
|
mergedCustomFields[keyInCustomFields] = {
|
||||||
|
type: 'text', // we currently only support text custom fields
|
||||||
|
colour: maybeExistingColour,
|
||||||
|
label: ontimeLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. add the column to the import keys
|
||||||
|
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
|
||||||
|
}
|
||||||
|
return { mergedCustomFields, customFieldImportKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function infers a boolean from a string value
|
||||||
|
*/
|
||||||
|
export function parseBooleanString(value: unknown): boolean {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// falsy values would be nullish or empty string
|
||||||
|
if (!value || typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.toLowerCase() !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexMap = Record<keyof Omit<ImportMap, 'worksheet' | 'custom'>, MaybeNumber> &
|
||||||
|
Record<keyof Pick<ImportMap, 'custom'>, Record<number, string>>;
|
||||||
|
|
||||||
|
export type SheetMetadata = Partial<
|
||||||
|
Record<keyof Omit<ImportMap, 'worksheet' | 'custom'>, { row: number; col: number }> &
|
||||||
|
Record<string, { row: number; col: number }>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function generateImportHandlers(importMap: ImportMap) {
|
||||||
|
const indexMap: IndexMap = {
|
||||||
|
title: null,
|
||||||
|
cue: null,
|
||||||
|
note: null,
|
||||||
|
colour: null,
|
||||||
|
flag: null,
|
||||||
|
skip: null,
|
||||||
|
countToEnd: null,
|
||||||
|
linkStart: null,
|
||||||
|
timeStart: null,
|
||||||
|
timeEnd: null,
|
||||||
|
duration: null,
|
||||||
|
timeWarning: null,
|
||||||
|
timeDanger: null,
|
||||||
|
endAction: null,
|
||||||
|
timerType: null,
|
||||||
|
entryId: null,
|
||||||
|
custom: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sheetMetadata: SheetMetadata = {};
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
[importMap.timeStart]: (row: number, col: number) => {
|
||||||
|
indexMap.timeStart = col;
|
||||||
|
sheetMetadata.timeStart = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.linkStart]: (row: number, col: number) => {
|
||||||
|
indexMap.linkStart = col;
|
||||||
|
sheetMetadata.linkStart = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.timeEnd]: (row: number, col: number) => {
|
||||||
|
indexMap.timeEnd = col;
|
||||||
|
sheetMetadata.timeEnd = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.duration]: (row: number, col: number) => {
|
||||||
|
indexMap.duration = col;
|
||||||
|
sheetMetadata.duration = { row, col };
|
||||||
|
},
|
||||||
|
|
||||||
|
[importMap.cue]: (row: number, col: number) => {
|
||||||
|
indexMap.cue = col;
|
||||||
|
sheetMetadata.cue = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.title]: (row: number, col: number) => {
|
||||||
|
indexMap.title = col;
|
||||||
|
sheetMetadata.title = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.flag]: (row: number, col: number) => {
|
||||||
|
indexMap.flag = col;
|
||||||
|
sheetMetadata.flag = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.countToEnd]: (row: number, col: number) => {
|
||||||
|
indexMap.countToEnd = col;
|
||||||
|
sheetMetadata.countToEnd = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.skip]: (row: number, col: number) => {
|
||||||
|
indexMap.skip = col;
|
||||||
|
sheetMetadata.skip = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.note]: (row: number, col: number) => {
|
||||||
|
indexMap.note = col;
|
||||||
|
sheetMetadata.note = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.colour]: (row: number, col: number) => {
|
||||||
|
indexMap.colour = col;
|
||||||
|
sheetMetadata.colour = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.endAction]: (row: number, col: number) => {
|
||||||
|
indexMap.endAction = col;
|
||||||
|
sheetMetadata.endAction = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.timerType]: (row: number, col: number) => {
|
||||||
|
indexMap.timerType = col;
|
||||||
|
sheetMetadata.timerType = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.timeWarning]: (row: number, col: number) => {
|
||||||
|
indexMap.timeWarning = col;
|
||||||
|
sheetMetadata.timeWarning = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.timeDanger]: (row: number, col: number) => {
|
||||||
|
indexMap.timeDanger = col;
|
||||||
|
sheetMetadata.timeDanger = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.entryId]: (row: number, col: number) => {
|
||||||
|
indexMap.entryId = col;
|
||||||
|
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
|
||||||
|
},
|
||||||
|
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
||||||
|
indexMap.custom[col] = columnText;
|
||||||
|
sheetMetadata[`custom:${ontimeKey}`] = { row, col };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handlers, indexMap, sheetMetadata };
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
|
|||||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||||
import { router as dbRouter } from './db/db.router.js';
|
import { router as dbRouter } from './db/db.router.js';
|
||||||
import { router as projectRouter } from './project-data/projectData.router.js';
|
import { router as projectRouter } from './project-data/projectData.router.js';
|
||||||
import { router as rundownRouter } from './rundown/rundown.router.js';
|
import { router as rundownsRouter } from './rundown/rundown.router.js';
|
||||||
import { router as settingsRouter } from './settings/settings.router.js';
|
import { router as settingsRouter } from './settings/settings.router.js';
|
||||||
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
||||||
import { router as excelRouter } from './excel/excel.router.js';
|
import { router as excelRouter } from './excel/excel.router.js';
|
||||||
@@ -20,7 +20,7 @@ appRouter.use('/automations', automationsRouter);
|
|||||||
appRouter.use('/custom-fields', customFieldsRouter);
|
appRouter.use('/custom-fields', customFieldsRouter);
|
||||||
appRouter.use('/db', dbRouter);
|
appRouter.use('/db', dbRouter);
|
||||||
appRouter.use('/project', projectRouter);
|
appRouter.use('/project', projectRouter);
|
||||||
appRouter.use('/rundown', rundownRouter);
|
appRouter.use('/rundowns', rundownsRouter);
|
||||||
appRouter.use('/settings', settingsRouter);
|
appRouter.use('/settings', settingsRouter);
|
||||||
appRouter.use('/sheets', sheetsRouter);
|
appRouter.use('/sheets', sheetsRouter);
|
||||||
appRouter.use('/excel', excelRouter);
|
appRouter.use('/excel', excelRouter);
|
||||||
@@ -30,7 +30,7 @@ appRouter.use('/view-settings', viewSettingsRouter);
|
|||||||
appRouter.use('/report', reportRouter);
|
appRouter.use('/report', reportRouter);
|
||||||
appRouter.use('/assets', assetsRouter);
|
appRouter.use('/assets', assetsRouter);
|
||||||
|
|
||||||
//we don't want to redirect to react index when using api routes
|
// we don't want to redirect to react index when using api routes
|
||||||
appRouter.all('/*splat', (_req, res) => {
|
appRouter.all('/*splat', (_req, res) => {
|
||||||
res.status(404).send('data path not found');
|
res.status(404).send('Unhandled request');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy, OntimeMilestone } from 'ontime-types';
|
||||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
makeOntimeGroup,
|
makeOntimeGroup,
|
||||||
makeOntimeDelay,
|
makeOntimeDelay,
|
||||||
makeCustomField,
|
makeCustomField,
|
||||||
|
makeOntimeMilestone,
|
||||||
} from '../__mocks__/rundown.mocks.js';
|
} from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
rundownMutation,
|
rundownMutation,
|
||||||
} from '../rundown.dao.js';
|
} from '../rundown.dao.js';
|
||||||
import { demoDb } from '../../../models/demoProject.js';
|
import { demoDb } from '../../../models/demoProject.js';
|
||||||
import type { AssignedMap } from '../rundown.types.js';
|
|
||||||
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
|
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
|
||||||
|
|
||||||
const setRundownMock = vi.fn();
|
const setRundownMock = vi.fn();
|
||||||
@@ -554,10 +554,6 @@ describe('processRundown()', () => {
|
|||||||
});
|
});
|
||||||
const initResult = processRundown(rundown, customProperties);
|
const initResult = processRundown(rundown, customProperties);
|
||||||
expect(initResult.order.length).toBe(2);
|
expect(initResult.order.length).toBe(2);
|
||||||
expect(initResult.assignedCustomFields).toMatchObject({
|
|
||||||
lighting: ['1', '2'],
|
|
||||||
sound: ['2'],
|
|
||||||
});
|
|
||||||
expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
|
expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
|
||||||
expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({
|
expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({
|
||||||
lighting: 'event 2 lx',
|
lighting: 'event 2 lx',
|
||||||
@@ -1742,22 +1738,26 @@ describe('customFieldMutation.renameUsages()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const assigned: AssignedMap = {
|
customFieldMutation.renameUsages(rundown, 'one', 'new-one');
|
||||||
one: ['1', '2'],
|
|
||||||
two: ['3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one');
|
|
||||||
expect(rundown.entries).toMatchObject({
|
expect(rundown.entries).toMatchObject({
|
||||||
'1': { id: '1', custom: { 'new-one': 'value1' } },
|
'1': { id: '1', custom: { 'new-one': 'value1' } },
|
||||||
'2': { id: '2', custom: { 'new-one': 'value2' } },
|
'2': { id: '2', custom: { 'new-one': 'value2' } },
|
||||||
'3': { id: '3', custom: { two: 'value3' } },
|
'3': { id: '3', custom: { two: 'value3' } },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(assigned).toStrictEqual({
|
it('renames usages inside groups and milestones', () => {
|
||||||
'new-one': ['1', '2'],
|
const rundown = makeRundown({
|
||||||
two: ['3'],
|
order: ['group', 'm1'],
|
||||||
|
entries: {
|
||||||
|
group: makeOntimeGroup({ id: 'group', entries: ['e1'] }),
|
||||||
|
e1: makeOntimeEvent({ id: 'e1', parent: 'group', custom: { one: 'v' } }),
|
||||||
|
m1: makeOntimeMilestone({ id: 'm1', custom: { two: 'keep' } }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
customFieldMutation.renameUsages(rundown, 'one', 'new-one');
|
||||||
|
expect((rundown.entries['e1'] as OntimeEvent).custom).toMatchObject({ 'new-one': 'v' });
|
||||||
|
expect((rundown.entries['m1'] as OntimeMilestone).custom).toMatchObject({ two: 'keep' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1772,17 +1772,8 @@ describe('customFieldMutation.removeUsages()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const assigned: AssignedMap = {
|
customFieldMutation.removeUsages(rundown, 'one');
|
||||||
one: ['1', '2'],
|
|
||||||
two: ['3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
customFieldMutation.removeUsages(rundown, assigned, 'one');
|
|
||||||
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
|
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
|
||||||
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
|
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
|
||||||
|
|
||||||
expect(assigned).toStrictEqual({
|
|
||||||
two: ['3'],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SupportedEntry, OntimeEvent, OntimeGroup, Rundown, CustomFields } from
|
|||||||
import { defaultRundown } from '../../../models/dataModel.js';
|
import { defaultRundown } from '../../../models/dataModel.js';
|
||||||
import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js';
|
import { parseRundowns, parseRundown, sanitiseCustomFields } from '../rundown.parser.js';
|
||||||
|
|
||||||
describe('parseRundowns()', () => {
|
describe('parseRundowns()', () => {
|
||||||
it('returns a default project rundown if nothing is given', () => {
|
it('returns a default project rundown if nothing is given', () => {
|
||||||
@@ -293,20 +293,8 @@ describe('parseRundown()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addToCustomAssignment()', () => {
|
describe('sanitiseCustomFields()', () => {
|
||||||
it('adds given entry to assignedCustomFields', () => {
|
it('deletes unused custom fields', () => {
|
||||||
const assignedCustomFields = {};
|
|
||||||
|
|
||||||
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
|
|
||||||
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
|
|
||||||
|
|
||||||
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
|
|
||||||
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handleCustomField()', () => {
|
|
||||||
it('creates a map of where custom fields are used', () => {
|
|
||||||
const customFields = {
|
const customFields = {
|
||||||
lighting: {
|
lighting: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -327,13 +315,11 @@ describe('handleCustomField()', () => {
|
|||||||
linkStart: true,
|
linkStart: true,
|
||||||
custom: {
|
custom: {
|
||||||
lighting: 'on',
|
lighting: 'on',
|
||||||
|
unknown: 'does-not-exist',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const assignedCustomFields = {};
|
|
||||||
|
|
||||||
const result = handleCustomField(customFields, event, assignedCustomFields);
|
sanitiseCustomFields(customFields, event);
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
|
|
||||||
expect(event.custom).toStrictEqual({
|
expect(event.custom).toStrictEqual({
|
||||||
lighting: 'on',
|
lighting: 'on',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
|
|||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import {
|
import {
|
||||||
applyPatchToEntry,
|
applyPatchToEntry,
|
||||||
cloneGroup,
|
cloneGroup,
|
||||||
@@ -67,15 +67,6 @@ let rundownMetadata: RundownMetadata = {
|
|||||||
flags: [],
|
flags: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const customFieldsMetadata: CustomFieldsMetadata = {
|
|
||||||
/**
|
|
||||||
* Keep track of which custom fields are used.
|
|
||||||
* This will be handy for when we delete custom fields
|
|
||||||
* since we can clear the custom fields from every event where they are used
|
|
||||||
*/
|
|
||||||
assigned: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The custom fields that are used in the project
|
* The custom fields that are used in the project
|
||||||
* Not unique to the loaded rundown
|
* Not unique to the loaded rundown
|
||||||
@@ -89,7 +80,6 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac
|
|||||||
|
|
||||||
type Transaction = {
|
type Transaction = {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
|
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
rundownMetadata: Readonly<RundownMetadata>;
|
rundownMetadata: Readonly<RundownMetadata>;
|
||||||
|
|
||||||
@@ -136,13 +126,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
const processedData = processRundown(rundown, projectCustomFields);
|
const processedData = processRundown(rundown, projectCustomFields);
|
||||||
// update the cache values
|
// update the cache values
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||||
processedData;
|
|
||||||
|
|
||||||
cachedRundown.entries = entries;
|
cachedRundown.entries = entries;
|
||||||
cachedRundown.order = order;
|
cachedRundown.order = order;
|
||||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||||
customFieldsMetadata.assigned = assignedCustomFields;
|
|
||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +155,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
customFields,
|
customFields,
|
||||||
customFieldsMetadata,
|
|
||||||
rundown,
|
rundown,
|
||||||
rundownMetadata,
|
rundownMetadata,
|
||||||
commit,
|
commit,
|
||||||
@@ -564,6 +551,15 @@ export const rundownMutation = {
|
|||||||
ungroup,
|
ungroup,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes a way to update a rundown which is not active
|
||||||
|
*/
|
||||||
|
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
|
||||||
|
setImmediate(async () => {
|
||||||
|
await getDataProvider().setRundown(rundownId, rundown);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new custom field to the object and returns it
|
* Adds a new custom field to the object and returns it
|
||||||
*/
|
*/
|
||||||
@@ -603,51 +599,29 @@ function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renames a custom field key in all the rundown entries that use it
|
* Iterates through all entries of a rundown and renames a custom field
|
||||||
*/
|
*/
|
||||||
function customFieldRenameUsages(
|
function customFieldRenameUsages(rundown: Rundown, oldKey: CustomFieldKey, newKey: CustomFieldKey) {
|
||||||
rundown: Rundown,
|
Object.keys(rundown.entries).forEach((entryId) => {
|
||||||
assigned: AssignedMap,
|
const entry = rundown.entries[entryId];
|
||||||
oldKey: CustomFieldKey,
|
if ('custom' in entry && entry.custom[oldKey]) {
|
||||||
newKey: CustomFieldKey,
|
// copy the data a new key and delete the old key
|
||||||
) {
|
entry.custom[newKey] = entry.custom[oldKey];
|
||||||
const usages = assigned[oldKey];
|
delete entry.custom[oldKey];
|
||||||
|
}
|
||||||
// iterate through all the entries that use the custom field
|
});
|
||||||
for (let i = 0; i < usages.length; i++) {
|
|
||||||
const entryId = usages[i];
|
|
||||||
const entry = rundown.entries[entryId] as OntimeEvent;
|
|
||||||
|
|
||||||
// copy the data a new key and delete the old key
|
|
||||||
entry.custom[newKey] = entry.custom[oldKey];
|
|
||||||
delete entry.custom[oldKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
// update assignment
|
|
||||||
assigned[newKey] = [...assigned[oldKey]];
|
|
||||||
delete assigned[oldKey];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes data for a custom field from all the entries that use it
|
* Iterates through all entries of a rundown and removes data associated with a custom field
|
||||||
*/
|
*/
|
||||||
function customFieldRemoveUsages(rundown: Rundown, assigned: AssignedMap, key: CustomFieldKey) {
|
function customFieldRemoveUsages(rundown: Rundown, key: CustomFieldKey) {
|
||||||
const usages = assigned[key];
|
Object.keys(rundown.entries).forEach((entryId) => {
|
||||||
if (!usages) {
|
const entry = rundown.entries[entryId];
|
||||||
return;
|
if ('custom' in entry && entry.custom[key]) {
|
||||||
}
|
delete entry.custom[key];
|
||||||
|
}
|
||||||
// iterate through all the entries that use the custom field
|
});
|
||||||
for (let i = 0; i < usages.length; i++) {
|
|
||||||
const entryId = usages[i];
|
|
||||||
const entry = rundown.entries[entryId] as OntimeEvent;
|
|
||||||
|
|
||||||
// delete the custom field entry
|
|
||||||
delete entry.custom[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
// update assignment
|
|
||||||
delete assigned[key];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customFieldMutation = {
|
export const customFieldMutation = {
|
||||||
@@ -672,13 +646,11 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
|||||||
projectCustomFields = customFields;
|
projectCustomFields = customFields;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||||
processedData;
|
|
||||||
cachedRundown.entries = entries;
|
cachedRundown.entries = entries;
|
||||||
cachedRundown.order = order;
|
cachedRundown.order = order;
|
||||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||||
cachedRundown.revision = rundown.revision;
|
cachedRundown.revision = rundown.revision;
|
||||||
customFieldsMetadata.assigned = assignedCustomFields;
|
|
||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
|
|
||||||
// defer writing to the database
|
// defer writing to the database
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
RundownEntries,
|
RundownEntries,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
isOntimeMilestone,
|
isOntimeMilestone,
|
||||||
|
OntimeMilestone,
|
||||||
|
OntimeGroup,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||||
|
|
||||||
@@ -172,40 +174,16 @@ export function parseRundown(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
* Ensures that custom fields have references
|
||||||
* @param label
|
* If a field is exists in the entry but not in the project customFields, it is deleted
|
||||||
* @param eventId
|
* Mutates the given event in place
|
||||||
*/
|
*/
|
||||||
export function addToCustomAssignment(
|
export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
|
||||||
key: CustomFieldKey,
|
for (const field in entry.custom) {
|
||||||
eventId: EntryId,
|
if (field in customFields) continue;
|
||||||
assignedCustomFields: Record<string, string[]>,
|
delete entry.custom[field];
|
||||||
) {
|
|
||||||
if (!Array.isArray(assignedCustomFields[key])) {
|
|
||||||
assignedCustomFields[key] = [];
|
|
||||||
}
|
|
||||||
assignedCustomFields[key].push(eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps track of which custom fields are assigned to which events
|
|
||||||
* Mutates the given assignedCustomFields in place
|
|
||||||
* If a field is referenced but is not in the customFields map, it is deleted
|
|
||||||
*/
|
|
||||||
export function handleCustomField(
|
|
||||||
customFields: CustomFields,
|
|
||||||
event: OntimeEvent,
|
|
||||||
assignedCustomFields: Record<CustomFieldKey, EntryId[]>,
|
|
||||||
) {
|
|
||||||
for (const field in event.custom) {
|
|
||||||
if (field in customFields) {
|
|
||||||
// add field to assignment map
|
|
||||||
addToCustomAssignment(field, event.id, assignedCustomFields);
|
|
||||||
} else {
|
|
||||||
// delete data if it is not declared in project level custom fields
|
|
||||||
delete event.custom[field];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProcessedRundownMetadata = RundownMetadata & {
|
export type ProcessedRundownMetadata = RundownMetadata & {
|
||||||
@@ -292,7 +270,7 @@ function processEntry<T extends OntimeEntry>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. handle custom fields - mutates currentEntry
|
// 2. handle custom fields - mutates currentEntry
|
||||||
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields);
|
sanitiseCustomFields(customFields, currentEntry);
|
||||||
|
|
||||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||||
currentEntry.dayOffset = processedData.totalDays;
|
currentEntry.dayOffset = processedData.totalDays;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
|
import { ErrorResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { generateId, getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
@@ -14,40 +14,36 @@ import {
|
|||||||
deleteEntries,
|
deleteEntries,
|
||||||
editEntry,
|
editEntry,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
|
initRundown,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
ungroupEntries,
|
ungroupEntries,
|
||||||
} from './rundown.service.js';
|
} from './rundown.service.js';
|
||||||
import {
|
import {
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
rundownBatchPutValidator,
|
entryBatchPutValidator,
|
||||||
|
entryPostValidator,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
rundownPutValidator,
|
entryPutValidator,
|
||||||
rundownReorderValidator,
|
entryReorderValidator,
|
||||||
rundownSwapValidator,
|
entrySwapValidator,
|
||||||
|
validateRundownMutation,
|
||||||
} from './rundown.validation.js';
|
} from './rundown.validation.js';
|
||||||
import { paramsWithId } from '../validation-utils/validationFunction.js';
|
import { paramsWithId } from '../validation-utils/validationFunction.js';
|
||||||
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import { defaultRundown } from '../../models/dataModel.js';
|
||||||
|
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
|
// #region operations on project rundowns =========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all rundowns in the project
|
* Returns all rundowns in the project
|
||||||
*/
|
*/
|
||||||
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||||
const rundown = getCurrentRundown();
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
|
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
// TODO: we currently make a project with only the current rundown
|
|
||||||
res.json({
|
|
||||||
loaded: rundown.id,
|
|
||||||
rundowns: [
|
|
||||||
{
|
|
||||||
id: rundown.id,
|
|
||||||
title: rundown.title,
|
|
||||||
numEntries: rundown.order.length,
|
|
||||||
revision: rundown.revision,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,113 +54,275 @@ router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
|||||||
res.json(rundown);
|
res.json(rundown);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', rundownPostValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
/**
|
||||||
|
* Loads a given rundown
|
||||||
|
*/
|
||||||
|
router.post('/:id/load', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const newEvent = await addEntry(req.body);
|
// maybe the rundown is already loaded
|
||||||
res.status(201).send(newEvent);
|
if (req.params.id === getCurrentRundown().id) {
|
||||||
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
|
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
const rundown = dataProvider.getRundown(req.params.id);
|
||||||
|
const customField = dataProvider.getCustomFields();
|
||||||
|
await initRundown(rundown, customField);
|
||||||
|
|
||||||
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
|
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/', rundownPutValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
/**
|
||||||
|
* Creates a new rundown
|
||||||
|
*/
|
||||||
|
router.post('/', rundownPostValidator, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const event = await editEntry(req.body);
|
const id = generateId();
|
||||||
res.status(200).send(event);
|
await getDataProvider().setRundown(id, { ...defaultRundown, id, title: req.body.title });
|
||||||
|
|
||||||
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
|
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
/**
|
||||||
|
* Deletes a rundown if not loaded
|
||||||
|
*/
|
||||||
|
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const rundown = await batchEditEntries(req.body.ids, req.body.data);
|
if (req.params.id === getCurrentRundown().id) {
|
||||||
res.status(200).send(rundown);
|
res.status(400).send({ message: 'Cannot delete loaded rundown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
const projectRundowns = dataProvider.getProjectRundowns();
|
||||||
|
|
||||||
|
if (Object.keys(projectRundowns).length <= 1) {
|
||||||
|
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
|
||||||
|
res.status(400).send({ message: 'Cannot delete the last rundown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dataProvider.deleteRundown(req.params.id);
|
||||||
|
const newProjectRundowns = getDataProvider().getProjectRundowns();
|
||||||
|
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
// #endregion operations on project rundowns ======================
|
||||||
try {
|
|
||||||
const { entryId, destinationId, order } = req.body;
|
|
||||||
const newRundown = await reorderEntry(entryId, destinationId, order);
|
|
||||||
res.status(200).send(newRundown);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
// #region operations on rundown entries ==========================
|
||||||
try {
|
|
||||||
const rundown = await swapEvents(req.body.from, req.body.to);
|
|
||||||
res.status(200).send(rundown);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.patch('/applydelay/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
/**
|
||||||
try {
|
* Creates a new entry in a given rundown
|
||||||
const newRundown = await applyDelay(req.params.id);
|
*/
|
||||||
res.status(200).send(newRundown);
|
router.post(
|
||||||
} catch (error) {
|
'/:rundownId/entry',
|
||||||
const message = getErrorMessage(error);
|
entryPostValidator,
|
||||||
res.status(400).send({ message });
|
validateRundownMutation,
|
||||||
}
|
async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||||
});
|
try {
|
||||||
|
const newEvent = await addEntry(req.body);
|
||||||
|
res.status(201).send(newEvent);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/clone/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
/**
|
||||||
try {
|
* Edits an entry in a given rundown
|
||||||
const newRundown = await cloneEntry(req.params.id);
|
*/
|
||||||
res.status(200).send(newRundown);
|
router.put(
|
||||||
} catch (error) {
|
'/:rundownId/entry',
|
||||||
const message = getErrorMessage(error);
|
entryPutValidator,
|
||||||
res.status(400).send({ message });
|
validateRundownMutation,
|
||||||
}
|
async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||||
});
|
try {
|
||||||
|
const event = await editEntry(req.body);
|
||||||
|
res.status(200).send(event);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/group', rundownArrayOfIds, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
/**
|
||||||
try {
|
* Edits an entry in a given rundown
|
||||||
const newRundown = await groupEntries(req.body.ids);
|
*/
|
||||||
res.status(200).send(newRundown);
|
router.put(
|
||||||
} catch (error) {
|
'/:rundownId/batch',
|
||||||
const message = getErrorMessage(error);
|
entryBatchPutValidator,
|
||||||
res.status(400).send({ message });
|
validateRundownMutation,
|
||||||
}
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
});
|
try {
|
||||||
|
const rundown = await batchEditEntries(req.body.ids, req.body.data);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/ungroup/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
/**
|
||||||
try {
|
* Reorders two entries in a rundown
|
||||||
const newRundown = await ungroupEntries(req.params.id);
|
*/
|
||||||
res.status(200).send(newRundown);
|
router.patch(
|
||||||
} catch (error) {
|
'/:rundownId/reorder',
|
||||||
const message = getErrorMessage(error);
|
entryReorderValidator,
|
||||||
res.status(400).send({ message });
|
validateRundownMutation,
|
||||||
}
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
});
|
try {
|
||||||
|
const { entryId, destinationId, order } = req.body;
|
||||||
|
const rundown = await reorderEntry(entryId, destinationId, order);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
/**
|
||||||
try {
|
* Applies a delay into the schedule
|
||||||
await deleteEntries(req.body.ids);
|
*/
|
||||||
res.status(204).send({ message: 'Events deleted' });
|
router.patch(
|
||||||
} catch (error) {
|
'/:rundownId/applydelay/:id',
|
||||||
const message = getErrorMessage(error);
|
paramsWithId,
|
||||||
res.status(400).send({ message });
|
validateRundownMutation,
|
||||||
}
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
});
|
try {
|
||||||
|
const rundown = await applyDelay(req.params.id);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.delete('/all', async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
|
/**
|
||||||
try {
|
* Swaps data between two Ontime events
|
||||||
const rundown = await deleteAllEntries();
|
*/
|
||||||
res.status(204).send(rundown);
|
router.patch(
|
||||||
} catch (error) {
|
'/:rundownId/swap',
|
||||||
const message = getErrorMessage(error);
|
entrySwapValidator,
|
||||||
res.status(400).send({ message });
|
validateRundownMutation,
|
||||||
}
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
});
|
try {
|
||||||
|
const rundown = await swapEvents(req.body.from, req.body.to);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones the contents of an entry into a new one
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:rundownId/clone/:id',
|
||||||
|
paramsWithId,
|
||||||
|
validateRundownMutation,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await cloneEntry(req.params.id);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group out of a list of entries
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:rundownId/group',
|
||||||
|
rundownArrayOfIds,
|
||||||
|
validateRundownMutation,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await groupEntries(req.body.ids);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dissolves a group by moving its children to the main rundown
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:rundownId/ungroup/:id',
|
||||||
|
paramsWithId,
|
||||||
|
validateRundownMutation,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await ungroupEntries(req.params.id);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a list of entries by their ID
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:rundownId/entries',
|
||||||
|
rundownArrayOfIds,
|
||||||
|
validateRundownMutation,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await deleteEntries(req.body.ids);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all entries in a given rundown
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:rundownId/all',
|
||||||
|
validateRundownMutation,
|
||||||
|
async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await deleteAllEntries();
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -12,16 +12,26 @@ import {
|
|||||||
PatchWithId,
|
PatchWithId,
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
Rundown,
|
Rundown,
|
||||||
|
LogOrigin,
|
||||||
|
ProjectRundowns,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { customFieldLabelToKey } from 'ontime-utils';
|
import { customFieldLabelToKey } from 'ontime-utils';
|
||||||
|
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||||
|
|
||||||
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
|
import {
|
||||||
|
createTransaction,
|
||||||
|
customFieldMutation,
|
||||||
|
rundownCache,
|
||||||
|
rundownMutation,
|
||||||
|
updateBackgroundRundown,
|
||||||
|
} from './rundown.dao.js';
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
|
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||||
|
import { logger } from '../../classes/Logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* creates a new entry with given data
|
* creates a new entry with given data
|
||||||
@@ -244,7 +254,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
|||||||
* Handles moving across root orders (a group order and top level order)
|
* Handles moving across root orders (a group order and top level order)
|
||||||
* @throws if entryId or destinationId not found
|
* @throws if entryId or destinationId not found
|
||||||
*/
|
*/
|
||||||
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
export async function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
// check that both entries exist
|
// check that both entries exist
|
||||||
@@ -383,8 +393,8 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
|||||||
// notify runtime that rundown has changed
|
// notify runtime that rundown has changed
|
||||||
updateRuntimeOnChange(rundownMetadata);
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
// we dont need to notify the timer since the grouping does not affect the runtime
|
// we need to notify the timer since we might be grouping a running event
|
||||||
notifyChanges(rundownMetadata, revision, { external: true });
|
notifyChanges(rundownMetadata, revision, { external: true, timer: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
return rundownResult;
|
return rundownResult;
|
||||||
@@ -454,8 +464,12 @@ export async function createCustomField(customField: CustomField): Promise<Custo
|
|||||||
* @throws if the label is missing or invalid
|
* @throws if the label is missing or invalid
|
||||||
* @throws if the new label already exists
|
* @throws if the new label already exists
|
||||||
*/
|
*/
|
||||||
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFields> {
|
export async function editCustomField(
|
||||||
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
key: CustomFieldKey,
|
||||||
|
newField: Partial<CustomField>,
|
||||||
|
projectRundowns: ProjectRundowns,
|
||||||
|
): Promise<CustomFields> {
|
||||||
|
const { customFields, rundown, commit } = createTransaction({
|
||||||
mutableRundown: true,
|
mutableRundown: true,
|
||||||
mutableCustomFields: true,
|
mutableCustomFields: true,
|
||||||
});
|
});
|
||||||
@@ -472,14 +486,22 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
|
|||||||
|
|
||||||
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
||||||
|
|
||||||
// if key has changed
|
// if key has changed ...
|
||||||
if (oldKey !== newKey) {
|
if (oldKey !== newKey) {
|
||||||
// 1. delete the old key
|
// ... reassign references in the active rundown
|
||||||
customFieldMutation.remove(customFields, oldKey);
|
customFieldMutation.renameUsages(rundown, oldKey, newKey);
|
||||||
if (oldKey in customFieldsMetadata.assigned) {
|
|
||||||
// 2. reassign references
|
// ... reassign references in the background rundowns
|
||||||
customFieldMutation.renameUsages(rundown, customFieldsMetadata.assigned, oldKey, newKey);
|
for (const rundownId of Object.keys(projectRundowns)) {
|
||||||
|
if (rundownId !== rundown.id) {
|
||||||
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||||
|
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
||||||
|
updateBackgroundRundown(rundown.id, backgroundRundown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ... delete the old key
|
||||||
|
customFieldMutation.remove(customFields, oldKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// the custom fields have been removed and there is no processing to be done
|
// the custom fields have been removed and there is no processing to be done
|
||||||
@@ -487,6 +509,7 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
|
|||||||
|
|
||||||
// schedule the side effects
|
// schedule the side effects
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
|
sendRefetch(RefetchKey.CustomFields);
|
||||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -496,8 +519,8 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
|
|||||||
/**
|
/**
|
||||||
* Deletes an existing custom field
|
* Deletes an existing custom field
|
||||||
*/
|
*/
|
||||||
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
export async function deleteCustomField(key: CustomFieldKey, projectRundowns: ProjectRundowns): Promise<CustomFields> {
|
||||||
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
const { customFields, rundown, commit } = createTransaction({
|
||||||
mutableRundown: true,
|
mutableRundown: true,
|
||||||
mutableCustomFields: true,
|
mutableCustomFields: true,
|
||||||
});
|
});
|
||||||
@@ -505,16 +528,27 @@ export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFiel
|
|||||||
return customFields;
|
return customFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
customFieldMutation.remove(customFields, key);
|
// remove references in the active rundown
|
||||||
if (key in customFieldsMetadata.assigned) {
|
customFieldMutation.removeUsages(rundown, key);
|
||||||
customFieldMutation.removeUsages(rundown, customFieldsMetadata.assigned, key);
|
|
||||||
|
// remove references in the background rundowns
|
||||||
|
for (const rundownId of Object.keys(projectRundowns)) {
|
||||||
|
if (rundownId !== rundown.id) {
|
||||||
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||||
|
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||||
|
updateBackgroundRundown(rundown.id, backgroundRundown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete the old key
|
||||||
|
customFieldMutation.remove(customFields, key);
|
||||||
|
|
||||||
// the custom fields have been removed and there is no processing to be done
|
// the custom fields have been removed and there is no processing to be done
|
||||||
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
||||||
|
|
||||||
// schedule the side effects
|
// schedule the side effects
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
|
sendRefetch(RefetchKey.CustomFields);
|
||||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -565,14 +599,20 @@ function notifyChanges(rundownMetadata: RundownMetadata, revision: number, optio
|
|||||||
* Sets a new rundown in the cache
|
* Sets a new rundown in the cache
|
||||||
* and marks it as the currently loaded one
|
* and marks it as the currently loaded one
|
||||||
*/
|
*/
|
||||||
export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
export async function initRundown(
|
||||||
|
rundown: Readonly<Rundown>,
|
||||||
|
customFields: Readonly<CustomFields>,
|
||||||
|
reload: boolean = false,
|
||||||
|
) {
|
||||||
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
||||||
|
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
|
||||||
// notify runtime that rundown has changed
|
// notify runtime that rundown has changed
|
||||||
updateRuntimeOnChange(rundownMetadata);
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
// notify timer of change
|
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload: true });
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
|
||||||
|
setLastLoadedRundown(rundown.id).catch((error) => {
|
||||||
|
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types';
|
import { EntryId, MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
export type RundownMetadata = {
|
export type RundownMetadata = {
|
||||||
totalDelay: number;
|
totalDelay: number;
|
||||||
@@ -12,8 +12,3 @@ export type RundownMetadata = {
|
|||||||
flatEntryOrder: EntryId[]; // flat order of entries
|
flatEntryOrder: EntryId[]; // flat order of entries
|
||||||
flags: EntryId[]; // flat order of flagged entries
|
flags: EntryId[]; // flat order of flagged entries
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
|
|
||||||
export type CustomFieldsMetadata = {
|
|
||||||
assigned: AssignedMap;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Rundown,
|
Rundown,
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
|
ProjectRundown,
|
||||||
|
ProjectRundowns,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
dayInMs,
|
dayInMs,
|
||||||
@@ -506,3 +508,12 @@ export function getTimedIndexFromPlayableIndex(metadata: RundownMetadata, index:
|
|||||||
const timedIndex = metadata.timedEventOrder.findIndex((id) => id === playableId);
|
const timedIndex = metadata.timedEventOrder.findIndex((id) => id === playableId);
|
||||||
return timedIndex;
|
return timedIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* converts a project rundowns map into an array of rundowns
|
||||||
|
*/
|
||||||
|
export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRundown[] {
|
||||||
|
return Object.values(rundowns).map(({ id, flatOrder, title, revision }) => {
|
||||||
|
return { id, numEntries: flatOrder.length, title, revision };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,40 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { body, param } from 'express-validator';
|
import { body, param } from 'express-validator';
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
|
||||||
|
|
||||||
export const rundownPostValidator = [
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
import { getCurrentRundown } from './rundown.dao.js';
|
||||||
|
|
||||||
|
// #region operations on project rundowns =========================
|
||||||
|
|
||||||
|
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
|
||||||
|
|
||||||
|
// #endregion operations on project rundowns ======================
|
||||||
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware prevents mutating a rundown that is not selected
|
||||||
|
* This allows our service to still only handle the current rundown
|
||||||
|
*
|
||||||
|
* This would need to be removed in favour or rundown selection if we would like
|
||||||
|
* to implement the mutation of background rundowns
|
||||||
|
*/
|
||||||
|
export async function validateRundownMutation(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const { rundownId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (getCurrentRundown().id !== rundownId) {
|
||||||
|
res.status(404).json({ message: 'Cannot mutate not selected rundown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({ message: 'Rundown not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const entryPostValidator = [
|
||||||
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
||||||
body('after').optional().isString(),
|
body('after').optional().isString(),
|
||||||
body('before').optional().isString(),
|
body('before').optional().isString(),
|
||||||
@@ -9,9 +42,9 @@ export const rundownPostValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const rundownPutValidator = [body('id').isString().notEmpty(), requestValidationFunction];
|
export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction];
|
||||||
|
|
||||||
export const rundownBatchPutValidator = [
|
export const entryBatchPutValidator = [
|
||||||
body('data').isObject(),
|
body('data').isObject(),
|
||||||
body('ids').isArray().notEmpty(),
|
body('ids').isArray().notEmpty(),
|
||||||
body('ids.*').isString(),
|
body('ids.*').isString(),
|
||||||
@@ -19,7 +52,7 @@ export const rundownBatchPutValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const rundownReorderValidator = [
|
export const entryReorderValidator = [
|
||||||
body('entryId').isString().notEmpty(),
|
body('entryId').isString().notEmpty(),
|
||||||
body('destinationId').isString().notEmpty(),
|
body('destinationId').isString().notEmpty(),
|
||||||
body('order').isIn(['before', 'after', 'insert']),
|
body('order').isIn(['before', 'after', 'insert']),
|
||||||
@@ -27,7 +60,7 @@ export const rundownReorderValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const rundownSwapValidator = [
|
export const entrySwapValidator = [
|
||||||
body('from').isString().notEmpty(),
|
body('from').isString().notEmpty(),
|
||||||
body('to').isString().notEmpty(),
|
body('to').isString().notEmpty(),
|
||||||
|
|
||||||
@@ -42,3 +75,5 @@ export const rundownArrayOfIds = [
|
|||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { publicDir } from '../../setup/index.js';
|
|||||||
import { socket } from '../../adapters/WebsocketAdapter.js';
|
import { socket } from '../../adapters/WebsocketAdapter.js';
|
||||||
import { getLastRequest } from '../../api-integration/integration.controller.js';
|
import { getLastRequest } from '../../api-integration/integration.controller.js';
|
||||||
import { getCurrentProject } from '../../services/project-service/ProjectService.js';
|
import { getCurrentProject } from '../../services/project-service/ProjectService.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||||
import { getTimezoneLabel } from '../../utils/time.js';
|
import { getTimezoneLabel } from '../../utils/time.js';
|
||||||
import { password, routerPrefix } from '../../externals.js';
|
import { password, routerPrefix } from '../../externals.js';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user