diff --git a/apps/cli/package.json b/apps/cli/package.json index 47d1cef9a..f4b09441b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getontime/cli", - "version": "4.0.0-alpha.4", + "version": "4.0.0-alpha.5", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/client/package.json b/apps/client/package.json index c36c76d6a..d9a546dcf 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "4.0.0-alpha.4", + "version": "4.0.0-alpha.5", "private": true, "type": "module", "dependencies": { @@ -29,6 +29,7 @@ "react-qr-code": "^2.0.18", "react-router": "^7.8.0", "react-simple-code-editor": "^0.14.1", + "react-virtuoso": "^4.14.0", "web-vitals": "^5.1.0", "zustand": "^5.0.7" }, diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 267690ac4..5d6c758ec 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,17 +1,11 @@ import axios, { AxiosResponse } from 'axios'; -import { - EntryId, - MessageResponse, - OntimeEntry, - OntimeEvent, - ProjectRundownsList, - Rundown, - TransientEventPayload, -} from 'ontime-types'; +import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types'; import { apiEntryUrl } from './constants'; -const rundownPath = `${apiEntryUrl}/rundown`; +const rundownPath = `${apiEntryUrl}/rundowns`; + +// #region operations on project rundowns ========================= /** * HTTP request to fetch a list of existing rundowns @@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise { } /** - * HTTP request to fetch all events + * HTTP request to fetch all entries in the currently loaded rundown */ export async function fetchCurrentRundown(): Promise { const res = await axios.get(`${rundownPath}/current`); return res.data; } +/** + * HTTP request to switch the currently loaded rundown + */ +export async function loadRundown(id: string): Promise> { + return axios.post(`${rundownPath}/${id}/load`); +} + +/** + * HTTP request to create a new rundown + */ +export async function createRundown(title: string): Promise> { + return axios.post(rundownPath, { title }); +} + +/** + * HTTP request to delete a rundown + */ +export async function deleteRundown(id: string): Promise> { + return axios.delete(`${rundownPath}/${id}`); +} + +// #endregion operations on project rundowns ====================== +// #region operations on rundown entries ========================== + /** * HTTP request to post new entry */ -export async function postAddEntry(data: TransientEventPayload): Promise> { - return axios.post(rundownPath, data); +export async function postAddEntry( + rundownId: string, + data: TransientEventPayload, +): Promise> { + return axios.post(`${rundownPath}/${rundownId}/entry`, data); } /** * HTTP request to edit an entry */ -export async function putEditEntry(data: Partial): Promise> { - return axios.put(rundownPath, data); +export async function putEditEntry(rundownId: string, data: Partial): Promise> { + return axios.put(`${rundownPath}/${rundownId}/entry`, data); } -type BatchEditEntry = { +export type BatchEditEntry = { data: Partial; - ids: string[]; + ids: EntryId[]; }; /** * HTTP request to edit multiple events */ -export async function putBatchEditEvents(data: BatchEditEntry): Promise> { - return axios.put(`${rundownPath}/batch`, data); +export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise> { + return axios.put(`${rundownPath}/${rundownId}/batch`, data); } export type ReorderEntry = { @@ -64,60 +85,57 @@ export type ReorderEntry = { /** * HTTP request to reorder an entry */ -export async function patchReorderEntry(data: ReorderEntry): Promise> { - return axios.patch(`${rundownPath}/reorder`, data); +export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); } -export type SwapEntry = { - from: string; - to: string; -}; - /** * HTTP request to swap two events */ -export async function requestEventSwap(data: SwapEntry): Promise> { - return axios.patch(`${rundownPath}/swap`, data); +export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); } /** * HTTP request to request application of delay */ -export async function requestApplyDelay(delayId: EntryId): Promise> { - return axios.patch(`${rundownPath}/applydelay/${delayId}`); +export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); } /** * HTTP request for cloning an entry */ -export async function postCloneEntry(entryId: EntryId): Promise> { - return axios.post(`${rundownPath}/clone/${entryId}`); -} - -/** - * HTTP request for dissolving of a group - */ -export async function requestUngroup(groupId: EntryId): Promise> { - return axios.post(`${rundownPath}/ungroup/${groupId}`); +export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise> { + return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); } /** * HTTP request for grouping a list of entries into a group */ -export async function requestGroupEntries(entryIds: EntryId[]): Promise> { - return axios.post(`${rundownPath}/group`, { ids: entryIds }); +export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise> { + 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> { - return axios.delete(rundownPath, { data: { ids: entryIds } }); +export async function requestUngroup(rundownId: string, groupId: EntryId): Promise> { + 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> { - return axios.delete(`${rundownPath}/all`); +export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise> { + 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> { + return axios.delete(`${rundownPath}/${rundownId}/all`); +} + +// #endregion operations on rundown entries ======================= diff --git a/apps/client/src/common/components/view-params-editor/InlineColourPicker.tsx b/apps/client/src/common/components/view-params-editor/InlineColourPicker.tsx index 705d59129..208dc543d 100644 --- a/apps/client/src/common/components/view-params-editor/InlineColourPicker.tsx +++ b/apps/client/src/common/components/view-params-editor/InlineColourPicker.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import SwatchPicker from '../input/colour-input/SwatchPicker'; @@ -16,10 +16,13 @@ const ensureHex = (value: string) => { return value; }; -export default function InlineColourPicker(props: InlineColourPickerProps) { - const { name, value } = props; +export default function InlineColourPicker({ name, value }: InlineColourPickerProps) { const [colour, setColour] = useState(() => ensureHex(value)); + useEffect(() => { + setColour(ensureHex(value)); + }, [value]); + return (
diff --git a/apps/client/src/common/components/view-params-editor/ParamInput.tsx b/apps/client/src/common/components/view-params-editor/ParamInput.tsx index 8d673665a..f335d9a2e 100644 --- a/apps/client/src/common/components/view-params-editor/ParamInput.tsx +++ b/apps/client/src/common/components/view-params-editor/ParamInput.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; +import { ComponentProps, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import Checkbox from '../checkbox/Checkbox'; import Input from '../input/input/Input'; -import Select from '../select/Select'; +import Select, { SelectOption } from '../select/Select'; import Switch from '../switch/Switch'; import InlineColourPicker from './InlineColourPicker'; @@ -35,7 +35,7 @@ export default function ParamInput({ paramField }: ParamInputProps) { return No options available; } - return ; + return ; } interface EditFormMultiOptionProps { @@ -83,7 +81,12 @@ function MultiOption({ paramField }: EditFormMultiOptionProps) { const { id, values, defaultValue = [''] } = paramField; const optionFromParams = searchParams.getAll(id); - const [paramState, setParamState] = useState(optionFromParams || defaultValue); + const [paramState, setParamState] = useState(optionFromParams.length ? optionFromParams : defaultValue); + + useEffect(() => { + const params = searchParams.getAll(id); + setParamState(params.length ? params : defaultValue); + }, [searchParams, id, defaultValue]); const toggleValue = (value: string, checked: boolean) => { if (checked) { @@ -129,5 +132,52 @@ interface ControlledSwitchProps { } function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) { const [checked, setChecked] = useState(initialValue); + + // synchronise checked state + useEffect(() => { + setChecked(initialValue); + }, [initialValue]); + return ; } + +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 ( + setValue(event.target.value as T)} + {...inputProps} + /> + ); +} diff --git a/apps/client/src/common/components/view-params-editor/ViewParamPresets.tsx b/apps/client/src/common/components/view-params-editor/ViewParamPresets.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index e771c8bb1..83c5193c9 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -1,4 +1,4 @@ -import { FormEvent, memo, useReducer } from 'react'; +import { FormEvent, memo } from 'react'; import { IoClose } from 'react-icons/io5'; import { useSearchParams } from 'react-router'; import { Dialog } from '@base-ui-components/react/dialog'; @@ -12,7 +12,7 @@ import Info from '../info/Info'; import { ViewOption } from './viewParams.types'; import { getURLSearchParamsFromObj } from './viewParams.utils'; import { useViewParamsEditorStore } from './viewParamsEditor.store'; -import { ViewParamsShare } from './ViewParamShare'; +import { ViewParamsPresets } from './ViewParamsPresets'; import ViewParamsSection from './ViewParamsSection'; import style from './ViewParamsEditor.module.scss'; @@ -24,12 +24,9 @@ interface EditFormDrawerProps { export default memo(ViewParamsEditor); function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { - // TODO: can we ensure that the options update when the user loads an alias? const [_, setSearchParams] = useSearchParams(); const { data: viewSettings } = useViewSettings(); const { isOpen, close } = useViewParamsEditorStore(); - // TODO: we dont want this as a permanent option - const forceRender = useReducer((x) => x + 1, 0)[1]; const handleClose = () => { close(); @@ -37,7 +34,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { const resetParams = () => { setSearchParams(); - forceRender(); }; const onParamsFormSubmit = (formEvent: FormEvent) => { @@ -46,7 +42,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); setSearchParams(newSearchParams); - forceRender(); }; return ( @@ -71,7 +66,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { {viewSettings.overrideStyles && ( This view style is being modified by a custom CSS file. )} - +
{viewOptions.map((section) => ( { - setSearchParams(`${preset.search}&alias=${preset.alias}`); + const newSearch = new URLSearchParams(preset.search); + newSearch.set('alias', preset.alias); + setSearchParams(newSearch); }; if (viewPresets.length === 0) { @@ -25,12 +27,12 @@ export function ViewParamsShare({ target }: { target: OntimeView }) { return (
{viewPresets.map((preset) => { - const active = window.location.search.includes(`alias=${preset.alias}`); + const active = searchParams.get('alias') === preset.alias; return (
{preset.alias}
-
{children}
); } diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index 0f1c458c4..65258cb03 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -106,7 +106,6 @@ $inner-padding: 1rem; th, td { padding: 0.5rem; - vertical-align: top; } tr:nth-child(even) { diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.module.scss index 2488127e6..4b2765753 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.module.scss @@ -6,14 +6,6 @@ width: 100%; } -.fieldForm { - padding: 1rem; - background-color: $gray-1350; - display: flex; - flex-direction: column; - gap: 1rem; -} - .twoCols { display: grid; grid-template-columns: 1fr 1fr; diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundownForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundownForm.tsx new file mode 100644 index 000000000..f941a2224 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundownForm.tsx @@ -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({ + 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 ( + + + + + + + + + {errors.root && {errors.root.message}} + + ); +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx index 513d04ae1..71c42cca9 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx @@ -1,18 +1,59 @@ +import { useState } from 'react'; import { IoAdd } from 'react-icons/io5'; import { useDisclosure } from '@mantine/hooks'; +import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import Dialog from '../../../../common/components/dialog/Dialog'; -import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; +import Tag from '../../../../common/components/tag/Tag'; +import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../../panel-utils/PanelUtils'; +import { ManageRundownForm } from './ManageRundownForm'; + import style from './ManagePanel.module.scss'; export default function ManageRundowns() { const { data } = useProjectRundowns(); - const [deleteOpen, deleteHandlers] = useDisclosure(); - const [loadOpen, loadHandlers] = useDisclosure(); + const { remove, load } = useMutateProjectRundowns(); + const [isOpenDelete, deleteHandlers] = useDisclosure(); + const [isOpenLoad, loadHandlers] = useDisclosure(); + const [isNewLoad, newHandlers] = useDisclosure(); + const [targetRundown, setTargetRundown] = useState(''); + const [actionError, setActionError] = useState(null); + + const openLoad = (id: string) => { + setActionError(null); + setTargetRundown(id); + loadHandlers.open(); + }; + + const openDelete = (id: string) => { + setActionError(null); + setTargetRundown(id); + deleteHandlers.open(); + }; + + const submitRundownLoad = async () => { + try { + await load(targetRundown); + } catch (error) { + setActionError(`Failed to load rundown. ${maybeAxiosError(error)}`); + } finally { + loadHandlers.close(); + } + }; + + const submitRundownDelete = async () => { + try { + await remove(targetRundown); + } catch (error) { + setActionError(`Failed to delete rundown. ${maybeAxiosError(error)}`); + } finally { + deleteHandlers.close(); + } + }; return ( <> @@ -21,49 +62,60 @@ export default function ManageRundowns() { Manage project rundowns - - - - - # Entries - Title - - - - - {data.rundowns.map((rundown) => { - const isLoaded = data.loaded === rundown.id; - return ( - - {rundown.numEntries} - {`${rundown.title}${isLoaded && ' (loaded)'}`} - - - - - - ); - })} - - + + {isNewLoad && } + {actionError && {actionError}} + + + + # Entries + Title + + + + + {data?.rundowns?.map(({ id, numEntries, title }) => { + const isLoaded = data.loaded === id; + return ( + + {numEntries} + + {title} {isLoaded && Loaded} + + + + + + + ); + })} + + + Cancel - } /> Cancel - diff --git a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx index 6f961da4d..cbb5884c4 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx @@ -83,11 +83,7 @@ export default function CustomFieldForm({ const isEditMode = initialKey !== undefined; return ( - preventEscape(event, onCancel)} - > + preventEscape(event, onCancel)}> Please note that images can quickly deteriorate your app's performance.
@@ -107,7 +103,7 @@ export default function CustomFieldForm({ />
-
+
+ -
+
+ +
-
+
+ {errors.root && {errors.root.message}} - + ); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss index 51364e155..23c0e0d70 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss @@ -12,6 +12,7 @@ tr .secondaryRow { } .linkStartActive { + flex-shrink: 0; color: $active-indicator; transform: rotate(-45deg); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx index 7d719ef6d..dd9251c8f 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx @@ -1,6 +1,6 @@ import { Fragment } from 'react'; import { IoLink } from 'react-icons/io5'; -import { CustomFields, isOntimeEvent, isOntimeGroup, Rundown } from 'ontime-types'; +import { CustomFields, isOntimeEvent, isOntimeGroup, isOntimeMilestone, Rundown } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import Tag from '../../../../../../common/components/tag/Tag'; @@ -53,9 +53,10 @@ export default function PreviewRundown(props: PreviewRundownProps) { - {rundown.order.map((entryId) => { + {rundown.flatOrder.map((entryId) => { const entry = rundown.entries[entryId]; if (isOntimeGroup(entry)) { + const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; return ( @@ -64,11 +65,75 @@ export default function PreviewRundown(props: PreviewRundownProps) { {entry.type} - - {entry.title} + {/** CUE */} + {entry.title} + {/** Flag */} + {/** Time Start */} + {/** Time End */} + {/** Duration */} + {/** Warning Time */} + {/** Danger Time */} + {/** Count to end */} + {/** Skip */} + {entry.colour} + {/** Timer Type */} + {/** End Action */} + {fieldKeys.map((field) => { + let value = ''; + if (field in entry.custom) { + value = entry.custom[field]; + } + return {value}; + })} + + {entry.id} + ); } + if (isOntimeMilestone(entry)) { + const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; + return ( + + + {/** Index */} + + {entry.type} + + {entry.cue} + {entry.title} + {/** Flag */} + {/** Time Start */} + {/** Time End */} + {/** Duration */} + {/** Warning Time */} + {/** Danger Time */} + {/** Count to end */} + {/** Skip */} + {entry.colour} + {/** Timer Type */} + {/** End Action */} + {fieldKeys.map((field) => { + let value = ''; + if (field in entry.custom) { + value = entry.custom[field]; + } + return {value}; + })} + + {entry.id} + + + {entry.note && ( + + + Note: {entry.note} + + + )} + + ); + } if (!isOntimeEvent(entry)) { return null; } @@ -107,14 +172,13 @@ export default function PreviewRundown(props: PreviewRundownProps) { {entry.endAction} - {isOntimeEvent(entry) && - fieldKeys.map((field) => { - let value = ''; - if (field in entry.custom) { - value = entry.custom[field]; - } - return {value}; - })} + {fieldKeys.map((field) => { + let value = ''; + if (field in entry.custom) { + value = entry.custom[field]; + } + return {value}; + })} {entry.id} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx index 762942922..90519f303 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx @@ -58,7 +58,7 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) { }; return ( - preventEscape(event, onClose)} @@ -76,11 +76,9 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) { {error && {error}} - + Project title + - + ); } diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 795a03b01..3c60f2cf3 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -37,13 +37,14 @@ import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { cloneEvent } from '../../common/utils/clone'; +import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { AppMode, sessionKeys } from '../../ontimeConfig'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; import RundownGroup from './rundown-group/RundownGroup'; import RundownGroupEnd from './rundown-group/RundownGroupEnd'; -import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils'; +import { canDrop, makeSortableList } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -53,13 +54,15 @@ const RundownEntry = lazy(() => import('./RundownEntry')); interface RundownProps { data: Rundown; + rundownMetadata: RundownMetadataObject; } -export default function Rundown({ data }: RundownProps) { +export default function Rundown({ data, rundownMetadata }: RundownProps) { const { order, entries, id } = data; // we create a copy of the rundown with a data structured aligned with what dnd-kit needs const featureData = useRundownEditor(); const [sortableData, setSortableData] = useState(() => makeSortableList(order, entries)); + const [metadata, setMetadata] = useState(rundownMetadata); const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ // we ensure that this is unique to the rundown key: `rundown.${id}-editor-collapsed-groups`, @@ -306,7 +309,8 @@ export default function Rundown({ data }: RundownProps) { // to workaround async updates on the drag mutations useEffect(() => { setSortableData(makeSortableList(order, entries)); - }, [order, entries]); + setMetadata(rundownMetadata); + }, [order, entries, rundownMetadata]); // in run mode, we follow selection useEffect(() => { @@ -334,19 +338,24 @@ export default function Rundown({ data }: RundownProps) { return; } - // prevent dropping a group inside another - if ( - active.data.current?.type === SupportedEntry.Group && - !canDrop(over.data.current?.type, over.data.current?.parent) - ) { + if (!active.data.current || !over.data.current) { return; } - const fromIndex = active.data.current?.sortable.index; - const toIndex = over.data.current?.sortable.index; + const fromIndex: number = active.data.current.sortable.index; + const toIndex: number = over.data.current.sortable.index; + let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before'; let destinationId = over.id as EntryId; - let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before'; + const isDraggingGroup = active.data.current?.type === SupportedEntry.Group; + + // prevent dropping a group inside another + if ( + isDraggingGroup && + !canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId)) + ) { + return; + } /** * We need to specially handle the end-group @@ -357,16 +366,25 @@ export default function Rundown({ data }: RundownProps) { if (destinationId.startsWith('end-')) { destinationId = destinationId.replace('end-', ''); // if we are moving before the end, we use the insert operation - if (order === 'before') { - order = 'insert'; + if (placement === 'before') { + placement = 'insert'; } } else { const group = data.entries[destinationId]; - if (isOntimeGroup(group) && order === 'after') { - if (group.entries.length === 0) order = 'insert'; - else { + // if dragging into a group + if (isOntimeGroup(group) && placement === 'after') { + if (isDraggingGroup) { + // ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before + // so we can safely push the dragged event after the group + destinationId = group.id; + } else if (group.entries.length === 0) { + // ... and the group is entry, we insert + destinationId = group.id; + placement = 'insert'; + } else { + // otherwise we add it to before the first group child destinationId = group.entries[0]; - order = 'before'; + placement = 'before'; } } } @@ -377,7 +395,7 @@ export default function Rundown({ data }: RundownProps) { setSortableData((currentEntries) => { return reorderArray(currentEntries, fromIndex, toIndex); }); - reorderEntry(active.id as EntryId, destinationId, order).catch((_) => { + reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => { setSortableData(currentEntries); }); }; @@ -418,11 +436,6 @@ export default function Rundown({ data }: RundownProps) { // 1. gather presentation options const isEditMode = editorMode === AppMode.Edit; - // 2. initialise rundown metadata - const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId); - // keep a single reference to the metadata which we override for every entry - let rundownMetadata = metadata; - return (
- {isEditMode && rundownMetadata.groupEntries === 0 && ( + {isEditMode && parentMetadata?.groupEntries === 0 && ( )} - + ); } @@ -466,15 +480,14 @@ export default function Rundown({ data }: RundownProps) { // this means that this can be out of sync with order until the useEffect runs // instead of writing all the logic guards, we simply short circuit rendering here const entry = entries[entryId]; - if (!entry) return null; - - rundownMetadata = process(entry); + const entryMetadata = metadata[entryId]; + if (!entry || !entryMetadata) return null; // if the entry has a parent, and it is collapsed, render nothing if ( entry.type !== SupportedEntry.Group && - rundownMetadata.groupId !== null && - getIsCollapsed(rundownMetadata.groupId) + entryMetadata.groupId !== null && + getIsCollapsed(entryMetadata.groupId) ) { return null; } @@ -488,7 +501,7 @@ export default function Rundown({ data }: RundownProps) { * ie: we are inside a group, but there is no defined colour * we default to $gray-500 #9d9d9d */ - const groupColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour; + const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour; const isFirst = index === 0; const isLast = entryId === order.at(-1); @@ -500,9 +513,8 @@ export default function Rundown({ data }: RundownProps) { * - when adding after, we can use the group ID directly to insert at the top of the group */ - const parentIdForBefore = - rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null; - const parentIdForAfter = rundownMetadata.groupId; + const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null; + const parentIdForAfter = entryMetadata.groupId; return ( @@ -513,7 +525,7 @@ export default function Rundown({ data }: RundownProps) { * - if it is not the first entry (the buttons would be there) */} {isEditMode && hasCursor && !isFirst && ( - + )} {isOntimeGroup(entry) ? ( {isOntimeEvent(entry) && (
{entry.flag && } -
{rundownMetadata.eventIndex}
+
{entryMetadata.eventIndex}
)}
@@ -562,13 +572,16 @@ export default function Rundown({ data }: RundownProps) { * - if the entry is not the group header */} {isEditMode && hasCursor && !isLast && ( - + )} ); })} {isEditMode && ( - + )}
diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index bbab55cf5..f8125b2d3 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -1,9 +1,7 @@ -import { useCallback } from 'react'; import { isOntimeDelay, isOntimeEvent, isOntimeMilestone, - MaybeString, OntimeEntry, OntimeEvent, Playback, @@ -12,26 +10,11 @@ import { import { useEntryActions } from '../../common/hooks/useEntryAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; -import { useEmitLog } from '../../common/stores/logger'; import { cloneEvent } from '../../common/utils/clone'; import RundownDelay from './rundown-delay/RundownDelay'; import RundownEvent from './rundown-event/RundownEvent'; import RundownMilestone from './rundown-milestone/RundownMilestone'; -import { useEventSelection } from './useEventSelection'; - -export type EventItemActions = - | 'event' - | 'event-before' - | 'delay' - | 'delay-before' - | 'group' - | 'group-before' - | 'swap' - | 'delete' - | 'clone' - | 'make-group' - | 'update'; interface RundownEntryProps { type: SupportedEntry; @@ -42,8 +25,6 @@ interface RundownEntryProps { hasCursor: boolean; isNext: boolean; isNextDay: boolean; - previousEntryId: MaybeString; - previousEventId?: string; playback?: Playback; // we only care about this if this event is playing isRolling: boolean; // we need to know even if not related to this event totalGap: number; @@ -56,8 +37,6 @@ export default function RundownEntry({ loaded, hasCursor, isNext, - previousEntryId, - previousEventId, playback, isRolling, eventIndex, @@ -65,105 +44,11 @@ export default function RundownEntry({ totalGap, isLinkedToLoaded, }: RundownEntryProps) { - const { emitError } = useEmitLog(); - const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); - const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); + const { addEntry } = useEntryActions(); - const removeOpenEvent = useCallback(() => { - unselect(data.id); - }, [unselect, data.id]); - - const clearMultiSelection = useCallback(() => { - clearSelectedEvents(); - }, [clearSelectedEvents]); - - // Create / delete new events - type FieldValue = { - field: keyof Omit | '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 = { id: data.id }; - - // if selected events are more than one - // we need to bulk edit - if (selectedEvents.size > 1) { - const changes: Partial = { [field]: value }; - batchUpdateEvents(changes, Array.from(selectedEvents)); - return; - } - if (field in data) { - // @ts-expect-error -- not sure how to type this - newData[field] = value; - return updateEntry(newData); - } - - return emitError(`Unknown field: ${field}`); - } - default: { - action satisfies never; - throw new Error(`Unhandled event ${action}`); - } - } + const createCloneEvent = useMemoisedFn(() => { + const newEvent = cloneEvent(data as OntimeEvent); + addEntry(newEvent, { after: data.id }); }); if (isOntimeEvent(data)) { @@ -198,7 +83,7 @@ export default function RundownEntry({ dayOffset={data.dayOffset} totalGap={totalGap} isLinkedToLoaded={isLinkedToLoaded} - actionHandler={actionHandler} + createCloneEvent={createCloneEvent} hasTriggers={data.triggers.length > 0} /> ); diff --git a/apps/client/src/features/rundown/RundownWrapper.tsx b/apps/client/src/features/rundown/RundownWrapper.tsx index 90c28157f..15fea84af 100644 --- a/apps/client/src/features/rundown/RundownWrapper.tsx +++ b/apps/client/src/features/rundown/RundownWrapper.tsx @@ -1,5 +1,5 @@ import Empty from '../../common/components/state/Empty'; -import useRundown from '../../common/hooks-query/useRundown'; +import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import RundownHeader from './rundown-header/RundownHeader'; import RundownHeaderMobile from './rundown-header/RundownHeaderMobile'; @@ -12,12 +12,16 @@ interface RundownWrapperProps { } export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) { - const { data, status } = useRundown(); + const { data, status, rundownMetadata } = useRundownWithMetadata(); return (
{isSmallDevice ? : } - {status === 'success' && data ? : } + {status === 'success' && data && rundownMetadata ? ( + + ) : ( + + )}
); } diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index 3241d1460..b67407857 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -1,293 +1,6 @@ -import { EntryId, OntimeDelay, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; +import { EntryId, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; -import { makeRundownMetadata, makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils'; - -describe('makeRundownMetadata()', () => { - it('processes nested rundown data', () => { - const selectedEventId = '12'; - const demoEvents = { - '1': { - id: '1', - type: SupportedEntry.Event, - parent: null, - timeStart: 0, - timeEnd: 1, - duration: 1, - dayOffset: 0, - gap: 0, - skip: false, - linkStart: false, - } as OntimeEvent, - group: { - id: 'group', - type: SupportedEntry.Group, - entries: ['11', 'delay', '12', '13'], - colour: 'red', - } as OntimeGroup, - '11': { - id: '11', - type: SupportedEntry.Event, - parent: 'group', - timeStart: 10, - timeEnd: 11, - duration: 1, - dayOffset: 0, - gap: 10, - skip: false, - linkStart: false, - } as OntimeEvent, - delay: { - id: 'delay', - type: SupportedEntry.Delay, - parent: 'group', - duration: 0, - } as OntimeDelay, - '12': { - id: '12', - type: SupportedEntry.Event, - parent: 'group', - timeStart: 11, - timeEnd: 12, - duration: 1, - dayOffset: 0, - gap: 0, - skip: false, - linkStart: true, - } as OntimeEvent, - '13': { - id: '13', - type: SupportedEntry.Event, - parent: 'group', - timeStart: 12, - timeEnd: 13, - duration: 1, - dayOffset: 0, - gap: 0, - skip: false, - linkStart: true, - } as OntimeEvent, - '2': { - id: '2', - type: SupportedEntry.Event, - parent: null, - timeStart: 20, - timeEnd: 21, - duration: 1, - dayOffset: 0, - gap: 7, - skip: false, - linkStart: false, - } as OntimeEvent, - }; - - const { metadata, process } = makeRundownMetadata(selectedEventId); - - expect(metadata).toStrictEqual({ - previousEvent: null, - latestEvent: null, - previousEntryId: null, - thisId: null, - eventIndex: 0, - isPast: true, - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: null, - groupColour: undefined, - groupEntries: undefined, - }); - - expect(process(demoEvents['1'])).toStrictEqual({ - previousEvent: null, - latestEvent: demoEvents['1'], - previousEntryId: null, - thisId: demoEvents['1'].id, - eventIndex: 1, // UI indexes are 1 based - isPast: true, - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: null, - groupColour: undefined, - groupEntries: undefined, - }); - - expect(process(demoEvents['group'])).toMatchObject({ - previousEvent: demoEvents['1'], - latestEvent: demoEvents['1'], - previousEntryId: demoEvents['1'].id, - thisId: demoEvents['group'].id, - eventIndex: 1, - isPast: true, - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: 'group', - groupColour: 'red', - }); - - expect(process(demoEvents['11'])).toMatchObject({ - previousEvent: demoEvents['1'], - latestEvent: demoEvents['11'], - previousEntryId: demoEvents['group'].id, - thisId: demoEvents['11'].id, - eventIndex: 2, - isPast: true, - isNextDay: false, - totalGap: 10, - isLinkedToLoaded: false, - isLoaded: false, - groupId: 'group', - groupColour: 'red', - }); - - expect(process(demoEvents['delay'])).toMatchObject({ - previousEvent: demoEvents['11'], - latestEvent: demoEvents['11'], - previousEntryId: demoEvents['11'].id, - thisId: demoEvents['delay'].id, - eventIndex: 2, - isPast: true, - isNextDay: false, - totalGap: 10, - isLinkedToLoaded: false, - isLoaded: false, - groupId: 'group', - groupColour: 'red', - }); - - expect(process(demoEvents['12'])).toMatchObject({ - previousEvent: demoEvents['11'], - latestEvent: demoEvents['12'], - previousEntryId: demoEvents['delay'].id, - thisId: demoEvents['12'].id, - eventIndex: 3, - isPast: false, - isNextDay: false, - totalGap: 10, - isLinkedToLoaded: false, - isLoaded: true, - groupId: 'group', - groupColour: 'red', - }); - - expect(process(demoEvents['13'])).toMatchObject({ - previousEvent: demoEvents['12'], - latestEvent: demoEvents['13'], - previousEntryId: demoEvents['12'].id, - thisId: demoEvents['13'].id, - eventIndex: 4, - isPast: false, - isNextDay: false, - totalGap: 10, - isLinkedToLoaded: true, - isLoaded: false, - groupId: 'group', - groupColour: 'red', - }); - - expect(process(demoEvents['2'])).toMatchObject({ - previousEvent: demoEvents['13'], - latestEvent: demoEvents['2'], - previousEntryId: demoEvents['13'].id, - thisId: demoEvents['2'].id, - eventIndex: 5, - isPast: false, - isNextDay: false, - totalGap: 17, - isLinkedToLoaded: false, - isLoaded: false, - groupId: null, - groupColour: undefined, - }); - }); - - it('populates previousEntries in groups', () => { - const rundownStartsWithGroup = { - group: { - id: 'group', - type: SupportedEntry.Group, - colour: 'red', - entries: ['1', '2'], - } as OntimeGroup, - '1': { - id: '1', - type: SupportedEntry.Event, - parent: 'group', - timeStart: 1, - timeEnd: 2, - duration: 1, - dayOffset: 0, - gap: 0, - skip: false, - linkStart: false, - } as OntimeEvent, - '2': { - id: '2', - type: SupportedEntry.Event, - parent: 'group', - timeStart: 2, - timeEnd: 3, - duration: 1, - dayOffset: 0, - gap: 0, - skip: false, - linkStart: false, - } as OntimeEvent, - }; - const { process } = makeRundownMetadata(null); - - expect(process(rundownStartsWithGroup.group)).toStrictEqual({ - previousEvent: null, - latestEvent: null, - previousEntryId: null, - thisId: rundownStartsWithGroup.group.id, - eventIndex: 0, - isPast: false, - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: rundownStartsWithGroup.group.id, - groupColour: 'red', - groupEntries: 2, - }); - - expect(process(rundownStartsWithGroup['1'])).toStrictEqual({ - previousEvent: null, - latestEvent: rundownStartsWithGroup['1'], - previousEntryId: rundownStartsWithGroup.group.id, - thisId: rundownStartsWithGroup['1'].id, - eventIndex: 1, - isPast: false, - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: rundownStartsWithGroup.group.id, - groupColour: 'red', - groupEntries: 2, - }); - expect(process(rundownStartsWithGroup['2'])).toStrictEqual({ - previousEvent: rundownStartsWithGroup['1'], - latestEvent: rundownStartsWithGroup['2'], - previousEntryId: rundownStartsWithGroup['1'].id, - thisId: rundownStartsWithGroup['2'].id, - eventIndex: 2, - isPast: false, - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: rundownStartsWithGroup.group.id, - groupColour: 'red', - groupEntries: 2, - }); - }); -}); +import { makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils'; describe('makeSortableList()', () => { it('generates a list with group ends', () => { @@ -327,7 +40,7 @@ describe('makeSortableList()', () => { expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']); }); - it('handles a list with a with just groups', () => { + it('handles a list with just groups', () => { const order = ['group-1', 'group-2']; const entries: RundownEntries = { 'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup, diff --git a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx index 361ebcaad..63ff35124 100644 --- a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; -import { isOntimeEvent, isOntimeGroup, OntimeEntry } from 'ontime-types'; +import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types'; import useRundown from '../../../common/hooks-query/useRundown'; import EventEditor from './EventEditor'; import GroupEditor from './GroupEditor'; +import MilestoneEditor from './MilestoneEditor'; import style from './EntryEditor.module.scss'; @@ -38,6 +39,14 @@ export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProp ); } + if (isOntimeMilestone(entry)) { + return ( +
+ +
+ ); + } + if (isOntimeGroup(entry)) { return (
diff --git a/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss b/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss index 4188f24c3..8752a92f8 100644 --- a/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss +++ b/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss @@ -1,10 +1,21 @@ -.entryEditor { +.cuesheetEditor, +.rundownEditor { max-height: 100%; display: flex; flex-direction: column; overflow-x: auto; } +.rundownEditor { + // width is locked to swatch picker elements + width: calc(15 * 2rem + 13 * 0.5rem); + + // we dont want a scrollbar when in the modal + .content { + overflow-y: scroll; + } +} + .content { padding-inline: 0.5rem 1.5rem; padding-bottom: 4rem; @@ -13,7 +24,6 @@ display: flex; flex-direction: column; gap: 1.5rem; - overflow-y: scroll; } .timeSettings { diff --git a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx index b934d2a47..07cf0a6a7 100644 --- a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx @@ -52,7 +52,7 @@ export default function RundownEntryEditor() { if (isOntimeEvent(entry)) { return ( -
+
@@ -61,7 +61,7 @@ export default function RundownEntryEditor() { if (isOntimeMilestone(entry)) { return ( -
+
); @@ -69,7 +69,7 @@ export default function RundownEntryEditor() { if (isOntimeGroup(entry)) { return ( -
+
); diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss index 18b2729b7..fc36726d6 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss @@ -1,7 +1,7 @@ .triggerForm { padding-block: 0.5rem; display: grid; - grid-template-columns: 1fr 1fr auto auto; + grid-template-columns: 8rem 1fr auto 2rem; gap: 0.5rem; align-items: center; } @@ -9,7 +9,7 @@ .trigger { padding: 0.25rem 0.5rem; display: grid; - grid-template-columns: 1fr 1fr auto; + grid-template-columns: 8rem 1fr 2rem; align-items: center; &:nth-child(even) { diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx index abafc2786..da60bf3cc 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx @@ -155,7 +155,7 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
{triggerLifeCycle} {automationTitle} - handleDelete(id)}> + handleDelete(id)}>
diff --git a/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.module.scss b/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.module.scss index dbc1c4d9c..26472a94e 100644 --- a/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.module.scss +++ b/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.module.scss @@ -6,6 +6,7 @@ height: 1px; background: $blue-500; + z-index: $zindex-floating; } .addButton { diff --git a/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.tsx b/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.tsx index 4ef6c5417..3d00a9435 100644 --- a/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.tsx +++ b/apps/client/src/features/rundown/entry-editor/quick-add-cursor/QuickAddInline.tsx @@ -9,68 +9,53 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import style from './QuickAddInline.module.scss'; interface QuickAddInlineProps { - previousEventId: MaybeString; + referenceEntryId: MaybeString; parentGroup: MaybeString; + placement: 'before' | 'after'; } export default memo(QuickAddInline); -function QuickAddInline({ previousEventId, parentGroup }: QuickAddInlineProps) { +function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) { const { addEntry } = useEntryActions(); - const addEvent = () => { - addEntry( - { - type: SupportedEntry.Event, - parent: parentGroup, - }, - { - after: previousEventId, - lastEventId: previousEventId, - }, - ); - }; - - const addDelay = () => { - addEntry( - { type: SupportedEntry.Delay, parent: parentGroup }, - { - lastEventId: previousEventId, - after: previousEventId, - }, - ); - }; - - const addMilestone = () => { - addEntry( - { type: SupportedEntry.Milestone, parent: parentGroup }, - { - lastEventId: previousEventId, - after: previousEventId, - }, - ); - }; - - const addGroup = () => { - if (parentGroup !== null) { - return; + const handleAddEntry = (type: SupportedEntry) => { + if (placement === 'before') { + addEntry( + { type, parent: type !== SupportedEntry.Group ? parentGroup : null }, + { + before: referenceEntryId, + }, + ); + } else { + addEntry( + { type, parent: type !== SupportedEntry.Group ? parentGroup : null }, + { + lastEventId: referenceEntryId, + after: referenceEntryId, + }, + ); } - addEntry( - { type: SupportedEntry.Group }, - { - lastEventId: previousEventId, - after: previousEventId, - }, - ); }; return (
handleAddEntry(SupportedEntry.Event) }, + { type: 'item', icon: IoAdd, label: 'Add Delay', onClick: () => handleAddEntry(SupportedEntry.Delay) }, + { + type: 'item', + icon: IoAdd, + label: 'Add Milestone', + onClick: () => handleAddEntry(SupportedEntry.Milestone), + }, + { + type: 'item', + icon: IoAdd, + label: 'Add Group', + onClick: () => handleAddEntry(SupportedEntry.Group), + disabled: parentGroup !== null, + }, ]} render={} > diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 537cc7a0f..2e067e382 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -12,12 +12,12 @@ import { import { TbFlagFilled } from 'react-icons/tb'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; +import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { isPlaybackActive } from 'ontime-utils'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; +import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; -import type { EventItemActions } from '../RundownEntry'; import { useEventIdSwapping } from '../useEventIdSwapping'; import { getSelectionMode, useEventSelection } from '../useEventSelection'; @@ -56,15 +56,7 @@ interface RundownEventProps { dayOffset: number; totalGap: number; isLinkedToLoaded: boolean; - actionHandler: ( - action: EventItemActions, - payload?: - | number - | { - field: keyof Omit | 'durationOverride'; - value: unknown; - }, - ) => void; + createCloneEvent: () => void; hasTriggers: boolean; } @@ -98,11 +90,13 @@ export default function RundownEvent({ dayOffset, totalGap, isLinkedToLoaded, - actionHandler, hasTriggers, + createCloneEvent, }: RundownEventProps) { const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); - const { selectedEvents, setSelectedEvents } = useEventSelection(); + const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); + + const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection(); const handleRef = useRef(null); const [isVisible, setIsVisible] = useState(false); @@ -113,37 +107,48 @@ export default function RundownEvent({ type: 'item', label: 'Link to previous', icon: IoLink, - onClick: () => - actionHandler('update', { - field: 'linkStart', - value: 'true', - }), + onClick: () => { + batchUpdateEvents({ linkStart: true }, Array.from(selectedEvents)); + }, }, { type: 'item', label: 'Unlink from previous', icon: IoUnlink, - onClick: () => - actionHandler('update', { - field: 'linkStart', - value: null, - }), + onClick: () => { + batchUpdateEvents({ linkStart: false }, Array.from(selectedEvents)); + }, }, { type: 'divider' }, - { type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('make-group') }, + { + type: 'item', + label: 'Group', + icon: IoFolder, + onClick: () => { + groupEntries(Array.from(selectedEvents)); + clearSelectedEvents(); + }, + disabled: parent !== null, + }, { type: 'divider' }, - { type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, + { + type: 'item', + label: 'Delete', + icon: IoTrash, + onClick: () => { + clearSelectedEvents(); + deleteEntry(Array.from(selectedEvents)); + }, + }, ] : [ { type: 'item', label: flag ? 'Remove flag' : 'Add flag', icon: TbFlagFilled, - onClick: () => - actionHandler('update', { - field: 'flag', - value: !flag, - }), + onClick: () => { + updateEntry({ id: eventId, flag: !flag }); + }, }, { type: 'divider' }, { @@ -157,7 +162,8 @@ export default function RundownEvent({ label: `Swap this event with ${selectedEventId ?? ''}`, icon: IoSwapVertical, onClick: () => { - actionHandler('swap', { field: 'id', value: selectedEventId }); + if (!selectedEventId) return; + swapEvents(selectedEventId, eventId); clearSelectedEventId(); }, disabled: selectedEventId == null || selectedEventId === eventId, @@ -166,10 +172,18 @@ export default function RundownEvent({ type: 'item', label: 'Clone', icon: IoDuplicateOutline, - onClick: () => actionHandler('clone'), + onClick: createCloneEvent, }, { type: 'divider' }, - { type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, + { + type: 'item', + label: 'Delete', + icon: IoTrash, + onClick: () => { + deleteEntry([eventId]); + unselect(eventId); + }, + }, ], ); diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index c558c31da..1078a3f10 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -92,7 +92,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: }; const binderColours = data.colour && getAccessibleColour(data.colour); - const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent); + const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent); const [planOffset, planOffsetLabel] = (() => { if (data.targetDuration === null) { diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss index 38ef8c19d..b44e43574 100644 --- a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss @@ -3,14 +3,13 @@ .milestone { @include block-styling; - margin-left: calc(2rem + 1px); // binder + border margin-block: 0.125rem; padding-right: 0.25rem; background-color: $gray-1050; // to override inline color: $section-white; // to override inline display: grid; - grid-template-columns: 2rem 1fr 3fr; + grid-template-columns: 2rem 0.4fr 1fr; align-items: center; height: $secondary-block-height; gap: 0.5rem; diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index 88e07d12d..e9f007632 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -1,129 +1,4 @@ -import { - EntryId, - isOntimeEvent, - isOntimeGroup, - isPlayableEvent, - MaybeString, - OntimeDelay, - OntimeEntry, - OntimeEvent, - OntimeMilestone, - PlayableEvent, - RundownEntries, - SupportedEntry, -} from 'ontime-types'; -import { checkIsNextDay, isNewLatest } from 'ontime-utils'; - -type RundownMetadata = { - previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators - latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far - previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events - thisId: MaybeString; - eventIndex: number; - isPast: boolean; - isNextDay: boolean; - totalGap: number; - isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event - isLoaded: boolean; - groupId: MaybeString; - groupColour: string | undefined; - groupEntries: number | undefined; -}; - -/** - * Creates a process function which aggregates the rundown metadata and event metadata - */ -export function makeRundownMetadata(selectedEventId: MaybeString) { - let rundownMeta: RundownMetadata = { - previousEvent: null, - latestEvent: null, - previousEntryId: null, - thisId: null, - eventIndex: 0, - isPast: Boolean(selectedEventId), // all events before the current selected are in the past - isNextDay: false, - totalGap: 0, - isLinkedToLoaded: false, - isLoaded: false, - groupId: null, - groupColour: undefined, - groupEntries: undefined, - }; - - function process(entry: OntimeEntry): Readonly { - 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, -): Readonly { - const processedData = { ...rundownMetadata }; - // initialise data to be overridden below - processedData.isNextDay = false; - processedData.isLoaded = false; - - processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration - processedData.thisId = entry.id; // we reassign thisId - processedData.previousEvent = processedData.latestEvent; - - if (entry.id === selectedEventId) { - processedData.isLoaded = true; - processedData.isPast = false; - } - - if (isOntimeGroup(entry)) { - processedData.groupId = entry.id; - processedData.groupColour = entry.colour; - processedData.groupEntries = entry.entries.length; - } else { - // for delays and groups, we insert the group metadata - if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { - // if the parent is not the current group, we need to update the groupId - processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent; - processedData.groupEntries = undefined; - if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) { - // if the entry has no parent, it cannot have a group colour - processedData.groupColour = undefined; - } - } - - if (isOntimeEvent(entry)) { - // event indexes are 1 based in UI - processedData.eventIndex += 1; - - if (isPlayableEvent(entry)) { - processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); - processedData.totalGap += entry.gap; - - if (!processedData.isPast && !processedData.isLoaded) { - /** - * isLinkToLoaded is a chain value that we maintain until we - * a) find an unlinked event - * b) find a countToEnd event - */ - processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd; - } - - if (isNewLatest(entry, processedData.latestEvent)) { - // this event is the forward most event in rundown, for next iteration - processedData.latestEvent = entry; - } - } - } - } - - return processedData; -} +import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; /** * Creates a sortable list of entries @@ -160,14 +35,23 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent * Checks whether a drop operation is valid * Currently only used for validating dropping groups */ -export function canDrop(targetType?: SupportedEntry | 'end-group', targetParent?: EntryId | null): boolean { +export function canDrop( + targetType: SupportedEntry | 'end-group', + targetParent: EntryId | null, + order?: 'after' | 'before', + isTargetCollapsed?: boolean, +): boolean { // this would mean inserting a group inside another if (targetType === 'end-group') { return false; } // this means swapping places with another group + // !!! if the user is dragging down, they could be inserting into a group depending on whether the group is collapsed if (targetType === 'group') { + if (order !== undefined && order === 'after' && !isTargetCollapsed) { + return false; + } return true; } diff --git a/apps/client/src/index.scss b/apps/client/src/index.scss index 9c58b9084..af9d5b564 100644 --- a/apps/client/src/index.scss +++ b/apps/client/src/index.scss @@ -40,8 +40,8 @@ } $track-color: $white-1; -$thumb-color: $gray-1100; -$thumb-color-hover: $gray-900; +$thumb-color: $white-20; +$thumb-color-hover: $white-60; /* Apply a natural box layout model to all elements */ html { @@ -157,7 +157,7 @@ input[type='number'] { /* Track */ ::-webkit-scrollbar-track { - background: $white-1; + background: $track-color; border-radius: 2px; } diff --git a/apps/client/src/views/countdown/Countdown.tsx b/apps/client/src/views/countdown/Countdown.tsx index 873216a78..783da2d9d 100644 --- a/apps/client/src/views/countdown/Countdown.tsx +++ b/apps/client/src/views/countdown/Countdown.tsx @@ -18,6 +18,7 @@ import { getCountdownOptions, useCountdownOptions } from './countdown.options'; import { getOrderedSubscriptions } from './countdown.utils'; import CountdownSelect from './CountdownSelect'; import CountdownSubscriptions from './CountdownSubscriptions'; +import SingleEventCountdown from './SingleEventCountdown'; import { CountdownData, useCountdownData } from './useCountdownData'; import './Countdown.scss'; @@ -116,6 +117,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun ); } + if (subscribedEvents.length === 1) { + const event = subscribedEvents.at(0); + if (!event) return null; + return ; + } + return ; } diff --git a/apps/client/src/views/countdown/SingleEventCountdown.scss b/apps/client/src/views/countdown/SingleEventCountdown.scss new file mode 100644 index 000000000..bea111232 --- /dev/null +++ b/apps/client/src/views/countdown/SingleEventCountdown.scss @@ -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; +} diff --git a/apps/client/src/views/countdown/SingleEventCountdown.tsx b/apps/client/src/views/countdown/SingleEventCountdown.tsx new file mode 100644 index 000000000..bd905d64e --- /dev/null +++ b/apps/client/src/views/countdown/SingleEventCountdown.tsx @@ -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 ( +
+ +
{subscribedEvent.title}
+
+ +
+
+ ); +} + +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 ( + <> +
{getLocalizedString(timerProgress[status])}
+
{timer}
+ + ); +} diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 8e16b2cfc..6c421cf3a 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -4,7 +4,6 @@ import { useSessionStorage } from '@mantine/hooks'; import EmptyPage from '../../common/components/state/EmptyPage'; import { PresetContext } from '../../common/context/PresetContext'; import useCustomFields from '../../common/hooks-query/useCustomFields'; -import { useFlatRundown } from '../../common/hooks-query/useRundown'; import { sessionScope } from '../../externals'; import { AppMode, sessionKeys } from '../../ontimeConfig'; @@ -15,7 +14,6 @@ import { useCuesheetPermissions } from './useTablePermissions'; export default memo(CuesheetTableWrapper); function CuesheetTableWrapper() { - const { data: flatRundown, status: rundownStatus } = useFlatRundown(); const { data: customFields, status: customFieldStatus } = useCustomFields(); const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const preset = use(PresetContext); @@ -52,15 +50,11 @@ function CuesheetTableWrapper() { [customFields, cuesheetMode, preset], ); - const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending'; + const isLoading = !customFields || customFieldStatus === 'pending'; return ( - {isLoading ? ( - - ) : ( - - )} + {isLoading ? : } ); } diff --git a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx index 2a76487f3..53298e9bf 100644 --- a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx @@ -9,12 +9,12 @@ import { useSensors, } from '@dnd-kit/core'; import { ColumnDef } from '@tanstack/react-table'; -import { OntimeEntry } from 'ontime-types'; +import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import useColumnManager from '../cuesheet-table/useColumnManager'; interface CuesheetDndProps { - columns: ColumnDef[]; + columns: ColumnDef[]; } export default function CuesheetDnd({ columns, children }: PropsWithChildren) { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss index 152f66310..b5236faa9 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss @@ -1,20 +1,22 @@ $table-font-size: 1rem; $table-header-font-size: calc(1rem - 2px); -.cuesheetContainer { - grid-area: table; - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: auto; - padding-bottom: 70vh; // allow focus to reach last elements -} - .cuesheet { font-size: $table-font-size; font-weight: 400; color: $ui-white; + padding-bottom: 70vh; // allow focus to reach last elements + + thead { + tr { + &::before { + content: ''; + display: block; + width: 4px; + height: 100%; + } + } + } tr { display: flex; @@ -30,10 +32,6 @@ $table-header-font-size: calc(1rem - 2px); width: 0.5rem; } } - - &:first-of-type { - margin-left: 4px; // compensate left border - } } th, @@ -82,7 +80,7 @@ $table-header-font-size: calc(1rem - 2px); .actionColumn { background-color: $gray-1250; - width: calc(2rem + 1px); // button + padding + margin + width: 2rem; // button + padding + margin } .indexColumn { width: 3.5em; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index 7237a4952..c6e4d343f 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -1,15 +1,23 @@ -import { memo, useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso'; import { useTableNav } from '@table-nav/react'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { OntimeEntry, TimeField } from 'ontime-types'; +import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types'; +import EmptyPage from '../../../common/components/state/EmptyPage'; +import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import { useEntryActions } from '../../../common/hooks/useEntryAction'; -import { useFollowSelected } from '../../../common/hooks/useFollowComponent'; +import { useSelectedEventId } from '../../../common/hooks/useSocket'; +import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown'; +import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { AppMode } from '../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../cuesheet.options'; -import CuesheetBody from './cuesheet-table-elements/CuesheetBody'; import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader'; +import DelayRow from './cuesheet-table-elements/DelayRow'; +import EventRow from './cuesheet-table-elements/EventRow'; +import GroupRow from './cuesheet-table-elements/GroupRow'; +import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import useColumnManager from './useColumnManager'; @@ -17,19 +25,20 @@ import useColumnManager from './useColumnManager'; import style from './CuesheetTable.module.scss'; interface CuesheetTableProps { - data: OntimeEntry[]; - columns: ColumnDef[]; + columns: ColumnDef[]; cuesheetMode: AppMode; } -export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetTableProps) { +export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTableProps) { + const { data, status } = useFlatRundownWithMetadata(); const { updateEntry, updateTimer } = useEntryActions(); const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes); const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds); const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); - const { selectedRef, scrollRef } = useFollowSelected(cuesheetMode === AppMode.Run); + const { selectedEventId } = useSelectedEventId(); + const virtuosoRef = useRef(null); const { listeners } = useTableNav(); const meta = useMemo( @@ -96,9 +105,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT setColumnSizing({}); }, [setColumnSizing]); - const headerGroups = table.getHeaderGroups(); - const rowModel = table.getRowModel(); - const allLeafColumns = table.getAllLeafColumns(); + // in run mode, we follow the selected row + useEffect(() => { + if (cuesheetMode === AppMode.Edit || virtuosoRef.current === null || !selectedEventId) { + return; + } + + const eventIndex = data.findIndex((event) => event.id === selectedEventId); + virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth' }); + }, [cuesheetMode, data, selectedEventId]); /** * To improve performance on resizing, we memoise the column sizes @@ -118,6 +133,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT // eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation }, [table.getState().columnSizingInfo, table.getState().columnSizing]); + const allLeafColumns = table.getAllLeafColumns(); + const { rows } = table.getRowModel(); + + const isLoading = !data || status === 'pending'; + + if (isLoading) { + return ; + } + return ( <> -
- - - {table.getState().columnSizingInfo.isResizingColumn ? ( - - ) : ( - - )} -
-
+ , + Table: ({ style: injectedStyles, ...virtuosoProps }) => { + return ( + + ); + }, + 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 ( + + ); + } + + if (isOntimeDelay(entry)) { + return ; + } + + if (isOntimeMilestone(entry)) { + return ( + + ); + } + + return ( + + ); + }, + TableHead: (virtuosoProps) => , + }} + fixedHeaderContent={() => { + return table + .getHeaderGroups() + .map((headerGroup) => ( + + )); + }} + /> + ); } - -/** - * 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; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx deleted file mode 100644 index 222571b43..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx +++ /dev/null @@ -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; - selectedRef: RefObject; - table: Table; -} - -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 ; - } - - return ( - - {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 ( - - ); - } - 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); - const parentEntry = rundown?.entries[entry.parent] as OntimeGroup | undefined; - parentBgColour = parentEntry?.colour ?? null; - } - return ; - } - 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); - const parentEntry = rundown?.entries[entry.parent]; - parentBgColour = (parentEntry as OntimeGroup | undefined)?.colour ?? null; - } - - return ( - - ); - } - 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); - const parentEntry = rundown?.entries[entry.parent] as OntimeGroup | undefined; - parentBgColour = parentEntry?.colour; - hadGroup = true; - } else if (hadGroup) { - firstAfterGroup = true; - hadGroup = false; - } - - return ( - - ); - } - - // currently there is no scenario where entryType is not handled above, either way... - return null; - })} - - ); -} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx index 1d2b7956e..9b62be8f6 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx @@ -1,8 +1,8 @@ import { CSSProperties } from 'react'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { flexRender, HeaderGroup } from '@tanstack/react-table'; -import { OntimeEntry } from 'ontime-types'; +import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { AppMode } from '../../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../../cuesheet.options'; @@ -12,54 +12,45 @@ import { SortableCell } from './SortableCell'; import style from '../CuesheetTable.module.scss'; interface CuesheetHeaderProps { - headerGroups: HeaderGroup[]; + headerGroup: HeaderGroup; cuesheetMode: AppMode; } -export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) { +export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) { const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); - return ( - - {headerGroups.map((headerGroup) => { - const key = headerGroup.id; + + {cuesheetMode === AppMode.Edit && + )} + + {headerGroup.headers.map((header) => { + const customBackground = header.column.columnDef.meta?.colour; + const canWrite = header.column.columnDef.meta?.canWrite; - return ( - - {cuesheetMode === AppMode.Edit && - )} - - {headerGroup.headers.map((header) => { - const customBackground = header.column.columnDef.meta?.colour; - const canWrite = header.column.columnDef.meta?.canWrite; + const customStyles: CSSProperties = { + opacity: canWrite ? 1 : 0.6, + }; + if (customBackground) { + const customColour = getAccessibleColour(customBackground); + customStyles.backgroundColor = customColour.backgroundColor; + customStyles.color = customColour.color; + } - const customStyles: CSSProperties = { - opacity: canWrite ? 1 : 0.6, - }; - if (customBackground) { - const customColour = getAccessibleColour(customBackground); - customStyles.backgroundColor = customColour.backgroundColor; - customStyles.color = customColour.color; - } - - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - - ); - })} - + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ); } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.module.scss index b57cb913a..691cffbfc 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.module.scss @@ -1,15 +1,13 @@ @import '../CuesheetTable.module.scss'; .delayRow { - width: calc(100vw - 2rem); color: $ontime-delay-text; - border-left: 4px solid var(--user-bg); + border-left: 4px solid transparent; td { - width: 100%; + width: calc(100% - 4px); padding-block: 0.5rem; text-align: center; - transform: translateX(45%); &:first-letter { text-transform: uppercase; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx index 83895e662..3d6562243 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx @@ -1,28 +1,26 @@ import { memo } from 'react'; import { millisToDelayString } from '../../../../common/utils/dateConfig'; +import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import style from './DelayRow.module.scss'; interface DelayRowProps { duration: number; - parentBgColour: string | null; } -function DelayRow({ duration, parentBgColour }: DelayRowProps) { +function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) { + const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays); + + if (hideDelays || duration === 0) { + return null; + } + const delayTime = millisToDelayString(duration, 'expanded'); return ( - - + + ); } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss index 639b46805..de06f12d3 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss @@ -12,7 +12,7 @@ } &.firstAfterGroup { - margin-top: 1rem; + margin-top: 2rem; } &.skip { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx index 7469dfbc2..d0a2b7cab 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -1,89 +1,91 @@ -import { RefObject, useEffect, useRef } from 'react'; +import { useMemo } from 'react'; import { IoEllipsisHorizontal } from 'react-icons/io5'; import { flexRender, Table } from '@tanstack/react-table'; -import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types'; +import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types'; import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import IconButton from '../../../../common/components/buttons/IconButton'; +import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { AppMode } from '../../../../ontimeConfig'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; -import { observeRow, unobserveRow } from './rowObserver'; -import { useVisibleRowsStore } from './visibleRowsStore'; - import style from './EventRow.module.scss'; interface EventRowProps { rowId: string; - event: OntimeEvent; + id: EntryId; eventIndex: number; + colour: string; + isFirstAfterGroup: boolean; + isLoaded: boolean; + isPast: boolean; + groupColour: string | undefined; + flag: boolean; + skip: boolean; + parent: EntryId | null; rowIndex: number; - isPast?: boolean; - selectedRef?: RefObject; - skip?: boolean; - colour?: string; - rowBgColour?: string; - parentBgColour?: string; - table: Table; - firstAfterGroup: boolean; + table: Table>; } export default function EventRow({ rowId, - event, + id, eventIndex, - rowIndex, + colour, + isFirstAfterGroup, + isLoaded, isPast, - selectedRef, - rowBgColour, - parentBgColour, + groupColour, + flag, + skip, + parent, + rowIndex, table, - firstAfterGroup, + ...virtuosoProps }: EventRowProps) { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { cuesheetMode: AppMode.Edit, hideIndexColumn: false, }; - const ownRef = useRef(null); - const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId)); const openMenu = useCuesheetTableMenu((store) => store.openMenu); - // register this row with the intersection observer - useEffect(() => { - const element = ownRef.current; - if (element) { - element.id = rowId; - observeRow(element); - } - - return () => { - if (element) { - unobserveRow(element); - } - }; - }, [rowId]); - - const { color, backgroundColor } = getAccessibleColour(event.colour); + const { color, backgroundColor } = getAccessibleColour(colour); const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }); + const rowBgColour: string | undefined = useMemo(() => { + if (isLoaded) { + return '#087A27'; // $active-green + } else if (colour) { + // the colour is user defined and might be invalid + const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor); + if (accessibleBackgroundColor !== null) { + return colourToHex({ + ...accessibleBackgroundColor, + alpha: accessibleBackgroundColor.alpha * 0.25, + }); + } + } + return; + }, [colour, isLoaded]); + return ( {cuesheetMode === AppMode.Edit && ( )} - {isVisible - ? table - .getRow(rowId) - .getVisibleCells() - .map((cell) => { - return ( - - ); - }) - : null} + {table + .getRow(rowId) + .getVisibleCells() + .map((cell) => { + return ( + + ); + })} ); } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.module.scss index ef701fa13..52841b021 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.module.scss @@ -1,7 +1,7 @@ @import '../CuesheetTable.module.scss'; .groupRow { - margin-top: 1rem; + margin-top: 2em; width: 100%; display: flex; align-items: start; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.tsx index 7f1dfce4b..9f3550cc6 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/GroupRow.tsx @@ -1,9 +1,9 @@ import { IoEllipsisHorizontal } from 'react-icons/io5'; import { flexRender, Table } from '@tanstack/react-table'; -import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types'; +import { EntryId, SupportedEntry } from 'ontime-types'; import IconButton from '../../../../common/components/buttons/IconButton'; -import { useCurrentGroupId } from '../../../../common/hooks/useSocket'; +import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import { AppMode } from '../../../../ontimeConfig'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; @@ -12,15 +12,12 @@ import style from './GroupRow.module.scss'; interface GroupRowProps { groupId: EntryId; colour: string; - hidePast: boolean; rowId: string; rowIndex: number; - table: Table; + table: Table; } -export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, table }: GroupRowProps) { - const { currentGroupId } = useCurrentGroupId(); - +export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { cuesheetMode: AppMode.Edit, hideIndexColumn: false, @@ -28,12 +25,8 @@ export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, t const openMenu = useCuesheetTableMenu((store) => store.openMenu); - if (hidePast && !currentGroupId) { - return null; - } - return ( - + {cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx index 7d0d568a6..3b7360989 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx @@ -2,12 +2,13 @@ import { CSSProperties, ReactNode } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Header } from '@tanstack/react-table'; -import { OntimeEntry } from 'ontime-types'; + +import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import style from '../CuesheetTable.module.scss'; interface SortableCellProps { - header: Header; + header: Header; injectedStyles: CSSProperties; children: ReactNode; } @@ -34,11 +35,9 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP {children}
header.column.resetSize(), - onMouseDown: header.getResizeHandler(), - onTouchStart: header.getResizeHandler(), - }} + onDoubleClick={() => header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} className={style.resizer} /> diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx index a8fd9b0bc..c63114a29 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx @@ -1,9 +1,10 @@ import { useCallback } from 'react'; import { CellContext, ColumnDef } from '@tanstack/react-table'; -import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy, URLPreset } from 'ontime-types'; +import { CustomFields, isOntimeDelay, isOntimeEvent, TimeStrategy, URLPreset } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; +import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import { formatDuration, formatTime } from '../../../../common/utils/time'; import { AppMode } from '../../../../ontimeConfig'; @@ -16,7 +17,7 @@ import MutedText from './MutedText'; import SingleLineCell from './SingleLineCell'; import TimeInput from './TimeInput'; -function MakeStart({ getValue, row, table, column }: CellContext) { +function MakeStart({ getValue, row, table, column }: CellContext) { if (!table.options.meta) { return null; } @@ -55,7 +56,7 @@ function MakeStart({ getValue, row, table, column }: CellContext) { +function MakeEnd({ getValue, row, table, column }: CellContext) { if (!table.options.meta) { return null; } @@ -95,7 +96,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext) { +function MakeDuration({ getValue, row, table, column }: CellContext) { if (!table.options.meta) { return null; } @@ -126,7 +127,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext) { +function MakeMultiLineField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, false); @@ -135,8 +136,8 @@ function MakeMultiLineField({ row, column, table }: CellContext; } -function LazyImage({ row, column, table }: CellContext) { +function LazyImage({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, true); @@ -166,7 +167,7 @@ function LazyImage({ row, column, table }: CellContext) { return ; } -function MakeSingleLineField({ row, column, table }: CellContext) { +function MakeSingleLineField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, false); @@ -175,8 +176,8 @@ function MakeSingleLineField({ row, column, table }: CellContext; } -function MakeFlagField({ row }: CellContext) { +function MakeFlagField({ row }: CellContext) { const event = row.original; if (!isOntimeEvent(event) || !event.flag) { return null; @@ -196,7 +197,7 @@ function MakeFlagField({ row }: CellContext) { return ; } -function MakeCustomField({ row, column, table }: CellContext) { +function MakeCustomField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, true); @@ -229,8 +230,8 @@ export function makeCuesheetColumns( customFields: CustomFields, cuesheetMode: AppMode, preset: URLPreset | undefined, -): ColumnDef[] { - const columnsDef: ColumnDef[] = []; +): ColumnDef[] { + const columnsDef: ColumnDef[] = []; const modeAllowsWrite = cuesheetMode === AppMode.Edit; const fullRead = preset ? preset.options?.read === 'full' : true; const fullWrite = preset ? preset.options?.write === 'full' : true; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx index f420fbccd..7c2fe5ed7 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -6,13 +6,13 @@ import { ToggleGroup } from '@base-ui-components/react/toggle-group'; import { Toolbar } from '@base-ui-components/react/toolbar'; import { useSessionStorage } from '@mantine/hooks'; import type { Column } from '@tanstack/react-table'; -import { OntimeEntry } from 'ontime-types'; import Button from '../../../../common/components/buttons/Button'; import Checkbox from '../../../../common/components/checkbox/Checkbox'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import PopoverContents from '../../../../common/components/popover/Popover'; import { PresetContext } from '../../../../common/context/PresetContext'; +import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import { cx } from '../../../../common/utils/styleUtils'; import { AppMode, sessionKeys } from '../../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../../cuesheet.options'; @@ -23,7 +23,7 @@ import CuesheetShareModal from './CuesheetShareModal'; import style from './CuesheetTableSettings.module.scss'; interface CuesheetTableSettingsProps { - columns: Column[]; + columns: Column[]; handleResetResizing: () => void; handleResetReordering: () => void; handleClearToggles: () => void; @@ -106,13 +106,6 @@ function ViewSettings() { /> Hide seconds in table - - options.setOption('hidePast', checked)} - /> - Hide past events - ) => { localStorage.setItem(tableSizesKey, JSON.stringify(sizes)); }, 500); -export default function useColumnManager(columns: ColumnDef[]) { +export default function useColumnManager(columns: ColumnDef[]) { const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: tableHiddenKey, defaultValue: {}, diff --git a/apps/client/src/views/cuesheet/cuesheet.options.ts b/apps/client/src/views/cuesheet/cuesheet.options.ts index 623b99e07..900483372 100644 --- a/apps/client/src/views/cuesheet/cuesheet.options.ts +++ b/apps/client/src/views/cuesheet/cuesheet.options.ts @@ -4,7 +4,6 @@ import { persist } from 'zustand/middleware'; type OptionValues = { hideTableSeconds: boolean; - hidePast: boolean; hideIndexColumn: boolean; showDelayedTimes: boolean; hideDelays: boolean; @@ -12,7 +11,6 @@ type OptionValues = { const defaultOptions: OptionValues = { hideTableSeconds: false, - hidePast: false, hideIndexColumn: false, showDelayedTimes: false, hideDelays: false, diff --git a/apps/electron/package.json b/apps/electron/package.json index 3c93240fe..8243c5af1 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "ontime-electron", - "version": "4.0.0-alpha.4", + "version": "4.0.0-alpha.5", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/server/package.json b/apps/server/package.json index 617419d43..af0b87f75 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,7 @@ "name": "ontime-server", "type": "module", "main": "src/index.ts", - "version": "4.0.0-alpha.4", + "version": "4.0.0-alpha.5", "exports": "./src/index.js", "dependencies": { "@googleapis/sheets": "^5.0.5", diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index 2c230926c..a7601c8c8 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -85,7 +85,7 @@ class SocketServer implements IAdapter { }); this.lastConnection = new Date(); - logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`); + logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`); sendPacket(MessageTag.ClientInit, { clientId, clientName }); @@ -98,7 +98,7 @@ class SocketServer implements IAdapter { ws.on('close', () => { this.clients.delete(clientId); - logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`); + logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`); this.sendClientList(); }); diff --git a/apps/server/src/api-data/custom-fields/customFields.router.ts b/apps/server/src/api-data/custom-fields/customFields.router.ts index 7de1de47e..16757d684 100644 --- a/apps/server/src/api-data/custom-fields/customFields.router.ts +++ b/apps/server/src/api-data/custom-fields/customFields.router.ts @@ -4,6 +4,8 @@ import { getErrorMessage } from 'ontime-utils'; import express from 'express'; import type { Request, Response } from 'express'; +import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; + import { getProjectCustomFields } from '../rundown/rundown.dao.js'; import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js'; @@ -11,37 +13,52 @@ import { validateCustomField, validateDeleteCustomField, validateEditCustomField export const router = express.Router(); +/** + * Gets all the custom fields for the project + */ router.get('/', async (_req: Request, res: Response) => { const customFields = getProjectCustomFields(); res.status(200).json(customFields); }); +/** + * Creates a new custom field + */ router.post('/', validateCustomField, async (req: Request, res: Response) => { try { const newFields = await createCustomField(req.body as CustomField); - res.status(201).send(newFields); + res.status(201).json(newFields); } catch (error) { const message = getErrorMessage(error); res.status(400).send({ message }); } }); +/** + * Modifies the properties of an existing custom field + */ router.put('/:key', validateEditCustomField, async (req: Request, res: Response) => { try { const currentKey = req.params.key; const { colour, type, label } = req.body; - const newFields = await editCustomField(currentKey, { label, colour, type }); - res.status(200).send(newFields); + + const projectRundowns = getDataProvider().getProjectRundowns(); + const newFields = await editCustomField(currentKey, { label, colour, type }, projectRundowns); + res.status(200).json(newFields); } catch (error) { const message = getErrorMessage(error); res.status(400).send({ message }); } }); +/** + * Deletes an existing custom field + */ router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response) => { try { - const customFields = await deleteCustomField(req.params.key); - res.status(200).send(customFields); + const projectRundowns = getDataProvider().getProjectRundowns(); + const customFields = await deleteCustomField(req.params.key, projectRundowns); + res.status(200).json(customFields); } catch (error) { const message = getErrorMessage(error); res.status(400).send({ message }); diff --git a/apps/server/src/api-data/db/db.parser.ts b/apps/server/src/api-data/db/db.parser.ts index 9c65b0352..a0d1dbcda 100644 --- a/apps/server/src/api-data/db/db.parser.ts +++ b/apps/server/src/api-data/db/db.parser.ts @@ -26,7 +26,6 @@ export function parseDatabaseModel(jsonData: Partial): { errors: ParsingError[]; migrated: boolean; } { - //TODO: TEST THIS!!!!!!! let migrated = false; let migratedData = jsonData; if (v3.shouldUseThisMigration(jsonData)) { diff --git a/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts b/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts index 12c8a78d3..ffcb90692 100644 --- a/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts +++ b/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts @@ -1,7 +1,7 @@ -import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types'; -import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; +import { CustomFields, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types'; +import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; -import { getCustomFieldData, parseExcel } from '../excel.parser.js'; +import { parseExcel } from '../excel.parser.js'; import { dataFromExcelTemplate } from './mockData.js'; @@ -156,11 +156,38 @@ describe('parseExcel()', () => { expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth'); }); - it('imports groups', () => { + it('imports group', () => { + const testdata = [ + ['Title', 'Timer type', 'duration'], + ['a group', 'group', '10m'], + ['an event', 'clock', '1m'], + ]; + + const importMap = { + title: 'title', + timerType: 'timer type', + duration: 'duration', + } as ImportMap; + + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstGroup = result.rundown.entries[result.rundown.order[0]]; + + expect(result.rundown.order.length).toBe(1); + expect(result.rundown.flatOrder.length).toBe(2); + expect((firstGroup as OntimeGroup).type).toBe(SupportedEntry.Group); + expect((firstGroup as OntimeGroup).targetDuration).toBe(10 * MILLIS_PER_MINUTE); + }); + + it('places event between groups inside the group', () => { const testdata = [ ['Title', 'Timer type'], ['a group', 'group'], ['an event', 'clock'], + ['an event', 'clock'], + ['an event', 'clock'], + ['a second group ', 'group'], + ['an event', 'clock'], + ['an event', 'clock'], ]; const importMap = { @@ -168,10 +195,17 @@ describe('parseExcel()', () => { timerType: 'timer type', }; const result = parseExcel(testdata, {}, 'testSheet', importMap); - const firstEvent = result.rundown.entries[result.rundown.order[0]]; + const firstGroup = result.rundown.entries[result.rundown.order[0]] as OntimeGroup; + const secondGroup = result.rundown.entries[result.rundown.order[1]] as OntimeGroup; expect(result.rundown.order.length).toBe(2); - expect((firstEvent as OntimeEvent).type).toBe(SupportedEntry.Group); + expect(result.rundown.flatOrder.length).toBe(7); + + expect(firstGroup.type).toBe(SupportedEntry.Group); + expect(firstGroup.entries.length).toBe(3); + + expect(secondGroup.type).toBe(SupportedEntry.Group); + expect(secondGroup.entries.length).toBe(2); }); it('imports as events if there is no timer type column', () => { @@ -314,8 +348,10 @@ describe('parseExcel()', () => { }; const result = parseExcel(testData, {}, 'testSheet', importMap); - expect(result.rundown.order.length).toBe(6); - expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']); + expect(result.rundown.order.length).toBe(5); + expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP']); + expect(result.rundown.flatOrder.length).toBe(6); + expect(result.rundown.flatOrder).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']); expect(result.rundown.entries).toMatchObject({ A: { @@ -417,162 +453,41 @@ describe('parseExcel()', () => { linkStart: true, }); }); -}); -describe('getCustomFieldData()', () => { - it('generates a list of keys from the given import map', () => { + it('handles milestones', () => { + const testdata = [ + ['Title', 'type', 'notes'], + ['event...', 'count-down', ''], + ['also event...', 'count-down', ''], + ['this i a milestone', 'milestone', 'milestone note'], + ]; + const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - linkStart: 'link start', - timeEnd: 'time end', - duration: 'duration', - flag: 'flag', - cue: 'cue', title: 'title', - countToEnd: 'count to end', - skip: 'skip', + timerType: 'type', note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: { - lighting: 'lx', - sound: 'sound', - video: 'av', - }, - entryId: 'id', } as ImportMap; - const result = getCustomFieldData(importMap, {}); - expect(result.mergedCustomFields).toStrictEqual({ - lighting: { - type: 'text', - colour: '', - label: 'lighting', - }, - sound: { - type: 'text', - colour: '', - label: 'sound', - }, - video: { - type: 'text', - colour: '', - label: 'video', - }, + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + const secondEvent = result.rundown.entries[result.rundown.order[1]]; + const milestone = result.rundown.entries[result.rundown.order[2]]; + + expect(result.rundown.order.length).toBe(3); + expect(firstEvent).toMatchObject({ + type: SupportedEntry.Event, + timerType: TimerType.CountDown, }); - // it is an inverted record of - expect(result.customFieldImportKeys).toStrictEqual({ - lx: 'lighting', - sound: 'sound', - av: 'video', - }); - }); - - it('keeps colour information from existing fields', () => { - const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - linkStart: 'link start', - timeEnd: 'time end', - duration: 'duration', - flag: 'flag', - cue: 'cue', - title: 'title', - countToEnd: 'count to end', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: { - lighting: 'lx', - sound: 'sound', - video: 'av', - 'ontime key': 'excel label', - }, - entryId: 'id', - } as ImportMap; - - const existingCustomFields: CustomFields = { - lighting: { label: 'lighting', type: 'text', colour: 'red' }, - sound: { label: 'sound', type: 'text', colour: 'green' }, - ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' }, - }; - - const result = getCustomFieldData(importMap, existingCustomFields); - expect(result.mergedCustomFields).toStrictEqual({ - lighting: { - type: 'text', - colour: 'red', - label: 'lighting', - }, - sound: { - type: 'text', - colour: 'green', - label: 'sound', - }, - video: { - type: 'text', - colour: '', - label: 'video', - }, - ontime_key: { - type: 'text', - colour: 'blue', - label: 'ontime key', - }, + expect(secondEvent).toMatchObject({ + type: SupportedEntry.Event, + timerType: TimerType.CountDown, }); - // it is an inverted record of - expect(result.customFieldImportKeys).toStrictEqual({ - lx: 'lighting', - sound: 'sound', - av: 'video', - 'excel label': 'ontime_key', - }); - }); - - it('lowercases the keys in the import map', () => { - const importMap: ImportMap = { - ...defaultImportMap, - custom: { - Lighting: 'Lx', - Sound: 'sound', - video: 'av', - }, - }; - - const result = getCustomFieldData(importMap, {}); - expect(result.mergedCustomFields).toStrictEqual({ - Lighting: { - type: 'text', - colour: '', - label: 'Lighting', - }, - Sound: { - type: 'text', - colour: '', - label: 'Sound', - }, - video: { - type: 'text', - colour: '', - label: 'video', - }, - }); - - // notice that the keys excel keys are lowercased - expect(result.customFieldImportKeys).toStrictEqual({ - lx: 'Lighting', - sound: 'Sound', - av: 'video', + expect(milestone).toMatchObject({ + type: SupportedEntry.Milestone, + title: 'this i a milestone', + note: 'milestone note', }); }); }); diff --git a/apps/server/src/api-data/excel/__tests__/excel.utils.test.ts b/apps/server/src/api-data/excel/__tests__/excel.utils.test.ts new file mode 100644 index 000000000..b10efc8a1 --- /dev/null +++ b/apps/server/src/api-data/excel/__tests__/excel.utils.test.ts @@ -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 + 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 + 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', + }); + }); +}); diff --git a/apps/server/src/api-data/excel/excel.parser.ts b/apps/server/src/api-data/excel/excel.parser.ts index 06ece46d6..8226ea917 100644 --- a/apps/server/src/api-data/excel/excel.parser.ts +++ b/apps/server/src/api-data/excel/excel.parser.ts @@ -7,7 +7,9 @@ import { SupportedEntry, isOntimeGroup, TimerType, - CustomFieldKey, + OntimeMilestone, + OntimeEntry, + isOntimeMilestone, } from 'ontime-types'; import { ImportMap, @@ -16,22 +18,26 @@ import { isKnownTimerType, validateTimerType, validateEndAction, - customFieldLabelToKey, - checkRegex, } from 'ontime-utils'; -import { Merge } from 'ts-essentials'; +import { Prettify } from 'ts-essentials'; import { is } from '../../utils/is.js'; import { makeString } from '../../utils/parserUtils.js'; import { parseExcelDate } from '../../utils/time.js'; +import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js'; + +type MergedOntimeEntry = Prettify< + Omit & OntimeGroup, keyof OntimeMilestone> & OntimeMilestone, 'type'> & { + type: SupportedEntry | 'group-end'; + } +>; /** * @description Excel array parser * @param {array} excelData - array with excel sheet * @param {ImportOptions} options - an object that contains the import map * @returns {object} - parsed object - * TODO: import milestones */ export const parseExcel = ( excelData: unknown[][], @@ -41,9 +47,8 @@ export const parseExcel = ( ): { rundown: Rundown; customFields: CustomFields; - rundownMetadata: Record; + sheetMetadata: SheetMetadata; } => { - const rundownMetadata: Record = {}; const importMap: ImportMap = { ...defaultImportMap, ...options }; for (const [key, value] of Object.entries(importMap)) { @@ -63,166 +68,74 @@ export const parseExcel = ( revision: 0, }; - // title stuff: strings - let titleIndex: number | null = null; - let cueIndex: number | null = null; - let notesIndex: number | null = null; - let colourIndex: number | null = null; - - // options: booleans - let flagIndex: number | null = null; - let skipIndex: number | null = null; - let countToEndIndex: number | null = null; - - let linkStartIndex: number | null = null; - - // times: numbers - let timeStartIndex: number | null = null; - let timeEndIndex: number | null = null; - let durationIndex: number | null = null; - let timeWarningIndex: number | null = null; - let timeDangerIndex: number | null = null; - - // options: enum properties - let endActionIndex: number | null = null; - let timerTypeIndex: number | null = null; - - //ID - let entryIdIndex: number | null = null; - - // record of column index and the name of the field - const customFieldIndexes: Record = {}; + // for placing entries into groups + let currentGroupId: string | null = null; + const groupEntries: string[] = []; + const { handlers, indexMap, sheetMetadata } = generateImportHandlers(importMap); excelData.forEach((row, rowIndex) => { if (row.length === 0) { return; } - // TODO: extract generating handlers from importMap - const handlers = { - [importMap.timeStart]: (row: number, col: number) => { - timeStartIndex = col; - rundownMetadata['timeStart'] = { row, col }; - }, - [importMap.linkStart]: (row: number, col: number) => { - linkStartIndex = col; - rundownMetadata['linkStart'] = { row, col }; - }, - [importMap.timeEnd]: (row: number, col: number) => { - timeEndIndex = col; - rundownMetadata['timeEnd'] = { row, col }; - }, - [importMap.duration]: (row: number, col: number) => { - durationIndex = col; - rundownMetadata['duration'] = { row, col }; - }, + const entry: Partial = {}; - [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> = {}; const entryCustomFields: EntryCustomFields = {}; for (let j = 0; j < row.length; j++) { const column = row[j]; // 1. we check if we have set a flag for a known field - if (j === timerTypeIndex) { - const maybeTimeType = makeString(column, ''); - if (maybeTimeType === 'group') { - // we leave this as a clue for the object filtering later on + if (j === indexMap.timerType) { + const maybeTimeType = makeString(column, '').toLowerCase(); + if (maybeTimeType === 'group' || maybeTimeType === 'group-start') { entry.type = SupportedEntry.Group; entry.entries = []; + } else if (maybeTimeType === 'group-end') { + entry.type = 'group-end'; + } else if (maybeTimeType === 'milestone') { + entry.type = SupportedEntry.Milestone; + } else if (maybeTimeType === 'skip-import') { + // intentional skip + return; } else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) { - // @ts-expect-error -- we leave this as a clue for the object filtering later on entry.type = SupportedEntry.Event; entry.timerType = validateTimerType(maybeTimeType); } else { // if it is not a group or a known type, we dont import it return; } - } else if (j === titleIndex) { + } else if (j === indexMap.title) { entry.title = makeString(column, ''); - } else if (j === timeStartIndex) { + } else if (j === indexMap.timeStart) { entry.timeStart = parseExcelDate(column); - } else if (j === linkStartIndex) { + } else if (j === indexMap.linkStart) { entry.linkStart = parseBooleanString(column); - } else if (j === timeEndIndex) { + } else if (j === indexMap.timeEnd) { entry.timeEnd = parseExcelDate(column); - } else if (j === durationIndex) { + } else if (j === indexMap.duration) { entry.duration = parseExcelDate(column); - } else if (j === cueIndex) { + } else if (j === indexMap.cue) { entry.cue = makeString(column, ''); - } else if (j === flagIndex) { + } else if (j === indexMap.flag) { entry.flag = parseBooleanString(column); - } else if (j === countToEndIndex) { + } else if (j === indexMap.countToEnd) { entry.countToEnd = parseBooleanString(column); - } else if (j === skipIndex) { + } else if (j === indexMap.skip) { entry.skip = parseBooleanString(column); - } else if (j === notesIndex) { + } else if (j === indexMap.note) { entry.note = makeString(column, ''); - } else if (j === endActionIndex) { + } else if (j === indexMap.endAction) { entry.endAction = validateEndAction(column); - } else if (j === timeWarningIndex) { + } else if (j === indexMap.timeWarning) { entry.timeWarning = parseExcelDate(column); - } else if (j === timeDangerIndex) { + } else if (j === indexMap.timeDanger) { entry.timeDanger = parseExcelDate(column); - } else if (j === colourIndex) { + } else if (j === indexMap.colour) { entry.colour = makeString(column, ''); - } else if (j === entryIdIndex) { + } else if (j === indexMap.entryId) { entry.id = encodeURIComponent(makeString(column, undefined)); - } else if (j in customFieldIndexes) { - const importKey = customFieldIndexes[j]; + } else if (j in indexMap.custom) { + const importKey = indexMap.custom[j]; const ontimeKey = customFieldImportKeys[importKey]; entryCustomFields[ontimeKey] = makeString(column, ''); } else { @@ -259,92 +172,78 @@ export const parseExcel = ( } const id = entry.id || generateId(); - // from excel, we can only get groups, milestones and events - if (isOntimeGroup(entry)) { - const group: OntimeGroup = { ...entry, custom: { ...entryCustomFields } }; - rundown.order.push(id); - rundown.entries[id] = group; + + if (entry.type === 'group-end') { + if (currentGroupId) { + (rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0); + currentGroupId = null; + } return; } + // from excel, we can only get groups, milestones and events + if (isOntimeGroup(entry as OntimeEntry)) { + const group = { + ...entry, + targetDuration: entry.duration ? entry.duration : null, + custom: { ...entryCustomFields }, + } as OntimeGroup; + + rundown.entries[id] = group; + if (currentGroupId) { + (rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0); + } + rundown.order.push(id); + rundown.flatOrder.push(id); + currentGroupId = id; + return; + } + + if (isOntimeMilestone(entry as OntimeEntry)) { + const milestone = { ...entry, custom: { ...entryCustomFields } } as OntimeMilestone; + if (currentGroupId) { + groupEntries.push(id); + milestone.parent = currentGroupId; + } else { + rundown.order.push(id); + } + rundown.flatOrder.push(id); + rundown.entries[id] = milestone; + return; + } + + //and fall through to treat it as an event const event = { ...entry, custom: { ...entryCustomFields }, type: SupportedEntry.Event, } as OntimeEvent; - if (timerTypeIndex === null) { + if (indexMap.timerType === null) { event.timerType = TimerType.CountDown; } - rundown.order.push(id); + + if (entry.linkStart === undefined) { + event.linkStart = true; + } + + if (currentGroupId) { + groupEntries.push(id); + event.parent = currentGroupId; + } else { + rundown.order.push(id); + } rundown.flatOrder.push(id); rundown.entries[id] = event; }); + if (currentGroupId) { + (rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0); + } + return { rundown, customFields: mergedCustomFields, - rundownMetadata, + sheetMetadata, }; }; - -/** - * Utility function infers a boolean from a string value - */ -function parseBooleanString(value: unknown): boolean { - if (typeof value === 'boolean') { - return value; - } - - // falsy values would be nullish or empty string - if (!value || typeof value !== 'string') { - return false; - } - return value.toLowerCase() !== 'false'; -} - -/** - * Receives an import map which contains custom field labels and a custom fields object - * the result importkeys is an inverted record of - * 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; -} { - const mergedCustomFields: CustomFields = {}; - /** - * A map of import keys to ontime keys - * Map - */ - const customFieldImportKeys: Record = {}; - - 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 }; -} diff --git a/apps/server/src/api-data/excel/excel.utils.ts b/apps/server/src/api-data/excel/excel.utils.ts new file mode 100644 index 000000000..428d85c2a --- /dev/null +++ b/apps/server/src/api-data/excel/excel.utils.ts @@ -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 + * 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; +} { + const mergedCustomFields: CustomFields = {}; + /** + * A map of import keys to ontime keys + * Map + */ + const customFieldImportKeys: Record = {}; + + 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, MaybeNumber> & + Record, Record>; + +export type SheetMetadata = Partial< + Record, { row: number; col: number }> & + Record +>; + +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 }; +} diff --git a/apps/server/src/api-data/index.ts b/apps/server/src/api-data/index.ts index cfc61e135..388bdf92f 100644 --- a/apps/server/src/api-data/index.ts +++ b/apps/server/src/api-data/index.ts @@ -5,7 +5,7 @@ import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js'; import { router as customFieldsRouter } from './custom-fields/customFields.router.js'; import { router as dbRouter } from './db/db.router.js'; import { router as projectRouter } from './project-data/projectData.router.js'; -import { router as rundownRouter } from './rundown/rundown.router.js'; +import { router as rundownsRouter } from './rundown/rundown.router.js'; import { router as settingsRouter } from './settings/settings.router.js'; import { router as sheetsRouter } from './sheets/sheets.router.js'; import { router as excelRouter } from './excel/excel.router.js'; @@ -20,7 +20,7 @@ appRouter.use('/automations', automationsRouter); appRouter.use('/custom-fields', customFieldsRouter); appRouter.use('/db', dbRouter); appRouter.use('/project', projectRouter); -appRouter.use('/rundown', rundownRouter); +appRouter.use('/rundowns', rundownsRouter); appRouter.use('/settings', settingsRouter); appRouter.use('/sheets', sheetsRouter); appRouter.use('/excel', excelRouter); @@ -30,7 +30,7 @@ appRouter.use('/view-settings', viewSettingsRouter); appRouter.use('/report', reportRouter); appRouter.use('/assets', assetsRouter); -//we don't want to redirect to react index when using api routes +// we don't want to redirect to react index when using api routes appRouter.all('/*splat', (_req, res) => { - res.status(404).send('data path not found'); + res.status(404).send('Unhandled request'); }); diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts index 682a38d34..ed6b5c618 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts @@ -1,4 +1,4 @@ -import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; +import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy, OntimeMilestone } from 'ontime-types'; import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; import { @@ -7,6 +7,7 @@ import { makeOntimeGroup, makeOntimeDelay, makeCustomField, + makeOntimeMilestone, } from '../__mocks__/rundown.mocks.js'; import { @@ -17,7 +18,6 @@ import { rundownMutation, } from '../rundown.dao.js'; import { demoDb } from '../../../models/demoProject.js'; -import type { AssignedMap } from '../rundown.types.js'; import { type ProcessedRundownMetadata } from '../rundown.parser.js'; const setRundownMock = vi.fn(); @@ -554,10 +554,6 @@ describe('processRundown()', () => { }); const initResult = processRundown(rundown, customProperties); expect(initResult.order.length).toBe(2); - expect(initResult.assignedCustomFields).toMatchObject({ - lighting: ['1', '2'], - sound: ['2'], - }); expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' }); expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 2 lx', @@ -1742,22 +1738,26 @@ describe('customFieldMutation.renameUsages()', () => { }, }); - const assigned: AssignedMap = { - one: ['1', '2'], - two: ['3'], - }; - - customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one'); + customFieldMutation.renameUsages(rundown, 'one', 'new-one'); expect(rundown.entries).toMatchObject({ '1': { id: '1', custom: { 'new-one': 'value1' } }, '2': { id: '2', custom: { 'new-one': 'value2' } }, '3': { id: '3', custom: { two: 'value3' } }, }); + }); - expect(assigned).toStrictEqual({ - 'new-one': ['1', '2'], - two: ['3'], + it('renames usages inside groups and milestones', () => { + const rundown = makeRundown({ + order: ['group', 'm1'], + entries: { + group: makeOntimeGroup({ id: 'group', entries: ['e1'] }), + e1: makeOntimeEvent({ id: 'e1', parent: 'group', custom: { one: 'v' } }), + m1: makeOntimeMilestone({ id: 'm1', custom: { two: 'keep' } }), + }, }); + customFieldMutation.renameUsages(rundown, 'one', 'new-one'); + expect((rundown.entries['e1'] as OntimeEvent).custom).toMatchObject({ 'new-one': 'v' }); + expect((rundown.entries['m1'] as OntimeMilestone).custom).toMatchObject({ two: 'keep' }); }); }); @@ -1772,17 +1772,8 @@ describe('customFieldMutation.removeUsages()', () => { }, }); - const assigned: AssignedMap = { - one: ['1', '2'], - two: ['3'], - }; - - customFieldMutation.removeUsages(rundown, assigned, 'one'); + customFieldMutation.removeUsages(rundown, 'one'); expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one'); expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one'); - - expect(assigned).toStrictEqual({ - two: ['3'], - }); }); }); diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts index 76affeefb..ead985caf 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts @@ -3,7 +3,7 @@ import { SupportedEntry, OntimeEvent, OntimeGroup, Rundown, CustomFields } from import { defaultRundown } from '../../../models/dataModel.js'; import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; -import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js'; +import { parseRundowns, parseRundown, sanitiseCustomFields } from '../rundown.parser.js'; describe('parseRundowns()', () => { it('returns a default project rundown if nothing is given', () => { @@ -293,20 +293,8 @@ describe('parseRundown()', () => { }); }); -describe('addToCustomAssignment()', () => { - it('adds given entry to assignedCustomFields', () => { - const assignedCustomFields = {}; - - addToCustomAssignment('label1', 'eventId 1', assignedCustomFields); - expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] }); - - addToCustomAssignment('label1', 'eventId 2', assignedCustomFields); - expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] }); - }); -}); - -describe('handleCustomField()', () => { - it('creates a map of where custom fields are used', () => { +describe('sanitiseCustomFields()', () => { + it('deletes unused custom fields', () => { const customFields = { lighting: { type: 'text', @@ -327,13 +315,11 @@ describe('handleCustomField()', () => { linkStart: true, custom: { lighting: 'on', + unknown: 'does-not-exist', }, }); - const assignedCustomFields = {}; - const result = handleCustomField(customFields, event, assignedCustomFields); - expect(result).toBeUndefined(); - expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] }); + sanitiseCustomFields(customFields, event); expect(event.custom).toStrictEqual({ lighting: 'on', }); diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index cfb7e3aed..085d38b15 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -29,7 +29,7 @@ import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js'; +import type { RundownMetadata } from './rundown.types.js'; import { applyPatchToEntry, cloneGroup, @@ -67,15 +67,6 @@ let rundownMetadata: RundownMetadata = { flags: [], }; -const customFieldsMetadata: CustomFieldsMetadata = { - /** - * Keep track of which custom fields are used. - * This will be handy for when we delete custom fields - * since we can clear the custom fields from every event where they are used - */ - assigned: {}, -}; - /** * The custom fields that are used in the project * Not unique to the loaded rundown @@ -89,7 +80,6 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac type Transaction = { customFields: CustomFields; - customFieldsMetadata: Readonly; rundown: Rundown; rundownMetadata: Readonly; @@ -136,13 +126,11 @@ export function createTransaction(options: TransactionOptions): Transaction { const processedData = processRundown(rundown, projectCustomFields); // update the cache values // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data - const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } = - processedData; + const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; cachedRundown.entries = entries; cachedRundown.order = order; cachedRundown.flatOrder = metadata.flatEntryOrder; - customFieldsMetadata.assigned = assignedCustomFields; rundownMetadata = metadata; } } @@ -167,7 +155,6 @@ export function createTransaction(options: TransactionOptions): Transaction { return { customFields, - customFieldsMetadata, rundown, rundownMetadata, commit, @@ -564,6 +551,15 @@ export const rundownMutation = { ungroup, }; +/** + * Exposes a way to update a rundown which is not active + */ +export function updateBackgroundRundown(rundownId: string, rundown: Rundown) { + setImmediate(async () => { + await getDataProvider().setRundown(rundownId, rundown); + }); +} + /** * Adds a new custom field to the object and returns it */ @@ -603,51 +599,29 @@ function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) { } /** - * Renames a custom field key in all the rundown entries that use it + * Iterates through all entries of a rundown and renames a custom field */ -function customFieldRenameUsages( - rundown: Rundown, - assigned: AssignedMap, - oldKey: CustomFieldKey, - newKey: CustomFieldKey, -) { - const usages = assigned[oldKey]; - - // iterate through all the entries that use the custom field - for (let i = 0; i < usages.length; i++) { - const entryId = usages[i]; - const entry = rundown.entries[entryId] as OntimeEvent; - - // copy the data a new key and delete the old key - entry.custom[newKey] = entry.custom[oldKey]; - delete entry.custom[oldKey]; - } - - // update assignment - assigned[newKey] = [...assigned[oldKey]]; - delete assigned[oldKey]; +function customFieldRenameUsages(rundown: Rundown, oldKey: CustomFieldKey, newKey: CustomFieldKey) { + Object.keys(rundown.entries).forEach((entryId) => { + const entry = rundown.entries[entryId]; + if ('custom' in entry && entry.custom[oldKey]) { + // copy the data a new key and delete the old key + entry.custom[newKey] = entry.custom[oldKey]; + delete entry.custom[oldKey]; + } + }); } /** - * Deletes data for a custom field from all the entries that use it + * Iterates through all entries of a rundown and removes data associated with a custom field */ -function customFieldRemoveUsages(rundown: Rundown, assigned: AssignedMap, key: CustomFieldKey) { - const usages = assigned[key]; - if (!usages) { - return; - } - - // iterate through all the entries that use the custom field - for (let i = 0; i < usages.length; i++) { - const entryId = usages[i]; - const entry = rundown.entries[entryId] as OntimeEvent; - - // delete the custom field entry - delete entry.custom[key]; - } - - // update assignment - delete assigned[key]; +function customFieldRemoveUsages(rundown: Rundown, key: CustomFieldKey) { + Object.keys(rundown.entries).forEach((entryId) => { + const entry = rundown.entries[entryId]; + if ('custom' in entry && entry.custom[key]) { + delete entry.custom[key]; + } + }); } export const customFieldMutation = { @@ -672,13 +646,11 @@ export function init(initialRundown: Readonly, initialCustomFields: Rea projectCustomFields = customFields; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data - const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } = - processedData; + const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; cachedRundown.entries = entries; cachedRundown.order = order; cachedRundown.flatOrder = metadata.flatEntryOrder; cachedRundown.revision = rundown.revision; - customFieldsMetadata.assigned = assignedCustomFields; rundownMetadata = metadata; // defer writing to the database diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index a1c011ead..c4d5fb1d9 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -14,6 +14,8 @@ import { RundownEntries, isPlayableEvent, isOntimeMilestone, + OntimeMilestone, + OntimeGroup, } from 'ontime-types'; import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; @@ -172,40 +174,16 @@ export function parseRundown( } /** - * Utility function to add an entry, mutates given assignedCustomFields in place - * @param label - * @param eventId + * Ensures that custom fields have references + * If a field is exists in the entry but not in the project customFields, it is deleted + * Mutates the given event in place */ -export function addToCustomAssignment( - key: CustomFieldKey, - eventId: EntryId, - assignedCustomFields: Record, -) { - 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, -) { - for (const field in event.custom) { - if (field in customFields) { - // add field to assignment map - addToCustomAssignment(field, event.id, assignedCustomFields); - } else { - // delete data if it is not declared in project level custom fields - delete event.custom[field]; - } +export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) { + for (const field in entry.custom) { + if (field in customFields) continue; + delete entry.custom[field]; } + return entry; } export type ProcessedRundownMetadata = RundownMetadata & { @@ -292,7 +270,7 @@ function processEntry( } // 2. handle custom fields - mutates currentEntry - handleCustomField(customFields, currentEntry, processedData.assignedCustomFields); + sanitiseCustomFields(customFields, currentEntry); processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent); currentEntry.dayOffset = processedData.totalDays; diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 692c41991..cb3b437ee 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -1,5 +1,5 @@ -import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; -import { getErrorMessage } from 'ontime-utils'; +import { ErrorResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; +import { generateId, getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; import express from 'express'; @@ -14,40 +14,36 @@ import { deleteEntries, editEntry, groupEntries, + initRundown, reorderEntry, swapEvents, ungroupEntries, } from './rundown.service.js'; import { rundownArrayOfIds, - rundownBatchPutValidator, + entryBatchPutValidator, + entryPostValidator, rundownPostValidator, - rundownPutValidator, - rundownReorderValidator, - rundownSwapValidator, + entryPutValidator, + entryReorderValidator, + entrySwapValidator, + validateRundownMutation, } from './rundown.validation.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(); +// #region operations on project rundowns ========================= + /** * Returns all rundowns in the project */ router.get('/', async (_req: Request, res: Response) => { - const rundown = getCurrentRundown(); - - // 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, - }, - ], - }); + const projectRundowns = getDataProvider().getProjectRundowns(); + res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); }); /** @@ -58,113 +54,275 @@ router.get('/current', async (_req: Request, res: Response) => { res.json(rundown); }); -router.post('/', rundownPostValidator, async (req: Request, res: Response) => { +/** + * Loads a given rundown + */ +router.post('/:id/load', paramsWithId, async (req: Request, res: Response) => { try { - const newEvent = await addEntry(req.body); - res.status(201).send(newEvent); + // maybe the rundown is already loaded + 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) { const message = getErrorMessage(error); res.status(400).send({ message }); } }); -router.put('/', rundownPutValidator, async (req: Request, res: Response) => { +/** + * Creates a new rundown + */ +router.post('/', rundownPostValidator, async (req: Request, res: Response) => { try { - const event = await editEntry(req.body); - res.status(200).send(event); + const id = generateId(); + 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) { const message = getErrorMessage(error); res.status(400).send({ message }); } }); -router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response) => { +/** + * Deletes a rundown if not loaded + */ +router.delete('/:id', paramsWithId, async (req: Request, res: Response) => { try { - const rundown = await batchEditEntries(req.body.ids, req.body.data); - res.status(200).send(rundown); + if (req.params.id === getCurrentRundown().id) { + 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) { const message = getErrorMessage(error); res.status(400).send({ message }); } }); -router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response) => { - 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 }); - } -}); +// #endregion operations on project rundowns ====================== -router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response) => { - 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 }); - } -}); +// #region operations on rundown entries ========================== -router.patch('/applydelay/:id', paramsWithId, async (req: Request, res: Response) => { - try { - const newRundown = await applyDelay(req.params.id); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); +/** + * Creates a new entry in a given rundown + */ +router.post( + '/:rundownId/entry', + entryPostValidator, + validateRundownMutation, + async (req: Request, res: Response) => { + 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) => { - try { - const newRundown = await cloneEntry(req.params.id); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); +/** + * Edits an entry in a given rundown + */ +router.put( + '/:rundownId/entry', + entryPutValidator, + validateRundownMutation, + async (req: Request, res: Response) => { + 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) => { - try { - const newRundown = await groupEntries(req.body.ids); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); +/** + * Edits an entry in a given rundown + */ +router.put( + '/:rundownId/batch', + entryBatchPutValidator, + validateRundownMutation, + async (req: Request, res: Response) => { + 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) => { - try { - const newRundown = await ungroupEntries(req.params.id); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); +/** + * Reorders two entries in a rundown + */ +router.patch( + '/:rundownId/reorder', + entryReorderValidator, + validateRundownMutation, + async (req: Request, res: Response) => { + 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) => { - try { - await deleteEntries(req.body.ids); - res.status(204).send({ message: 'Events deleted' }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); +/** + * Applies a delay into the schedule + */ +router.patch( + '/:rundownId/applydelay/:id', + paramsWithId, + validateRundownMutation, + async (req: Request, res: Response) => { + 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) => { - try { - const rundown = await deleteAllEntries(); - res.status(204).send(rundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -}); +/** + * Swaps data between two Ontime events + */ +router.patch( + '/:rundownId/swap', + entrySwapValidator, + validateRundownMutation, + async (req: Request, res: Response) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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 ======================= diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index f4142f84c..8e65cbb9b 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -12,16 +12,26 @@ import { PatchWithId, RefetchKey, Rundown, + LogOrigin, + ProjectRundowns, } from 'ontime-types'; import { customFieldLabelToKey } from 'ontime-utils'; 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 { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.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 @@ -244,7 +254,7 @@ export async function deleteAllEntries(): Promise { * Handles moving across root orders (a group order and top level order) * @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 }); // check that both entries exist @@ -383,8 +393,8 @@ export async function groupEntries(entryIds: EntryId[]): Promise { // notify runtime that rundown has changed updateRuntimeOnChange(rundownMetadata); - // we dont need to notify the timer since the grouping does not affect the runtime - notifyChanges(rundownMetadata, revision, { external: true }); + // we need to notify the timer since we might be grouping a running event + notifyChanges(rundownMetadata, revision, { external: true, timer: true }); }); return rundownResult; @@ -454,8 +464,12 @@ export async function createCustomField(customField: CustomField): Promise): Promise { - const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({ +export async function editCustomField( + key: CustomFieldKey, + newField: Partial, + projectRundowns: ProjectRundowns, +): Promise { + const { customFields, rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true, }); @@ -472,14 +486,22 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial { + sendRefetch(RefetchKey.CustomFields); notifyChanges(rundownMetadata, revision, { timer: true, external: true }); }); @@ -496,8 +519,8 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial { - const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({ +export async function deleteCustomField(key: CustomFieldKey, projectRundowns: ProjectRundowns): Promise { + const { customFields, rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true, }); @@ -505,16 +528,27 @@ export async function deleteCustomField(key: CustomFieldKey): Promise { + sendRefetch(RefetchKey.CustomFields); 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 * and marks it as the currently loaded one */ -export async function initRundown(rundown: Readonly, customFields: Readonly) { +export async function initRundown( + rundown: Readonly, + customFields: Readonly, + reload: boolean = false, +) { const { rundownMetadata, revision } = rundownCache.init(rundown, customFields); - + logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`); // notify runtime that rundown has changed updateRuntimeOnChange(rundownMetadata); - // notify timer of change 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}`); + }); }); } diff --git a/apps/server/src/api-data/rundown/rundown.types.ts b/apps/server/src/api-data/rundown/rundown.types.ts index ce0fc2da7..a6409063b 100644 --- a/apps/server/src/api-data/rundown/rundown.types.ts +++ b/apps/server/src/api-data/rundown/rundown.types.ts @@ -1,4 +1,4 @@ -import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types'; +import { EntryId, MaybeNumber } from 'ontime-types'; export type RundownMetadata = { totalDelay: number; @@ -12,8 +12,3 @@ export type RundownMetadata = { flatEntryOrder: EntryId[]; // flat order of entries flags: EntryId[]; // flat order of flagged entries }; - -export type AssignedMap = Record; -export type CustomFieldsMetadata = { - assigned: AssignedMap; -}; diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 1b6c4e534..90788a4be 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -15,6 +15,8 @@ import { Rundown, SupportedEntry, TimeStrategy, + ProjectRundown, + ProjectRundowns, } from 'ontime-types'; import { dayInMs, @@ -506,3 +508,12 @@ export function getTimedIndexFromPlayableIndex(metadata: RundownMetadata, index: const timedIndex = metadata.timedEventOrder.findIndex((id) => id === playableId); 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 }; + }); +} diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 29563fb8a..e44c0a619 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -1,7 +1,40 @@ +import type { Request, Response, NextFunction } from 'express'; 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('after').optional().isString(), body('before').optional().isString(), @@ -9,9 +42,9 @@ export const rundownPostValidator = [ 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('ids').isArray().notEmpty(), body('ids.*').isString(), @@ -19,7 +52,7 @@ export const rundownBatchPutValidator = [ requestValidationFunction, ]; -export const rundownReorderValidator = [ +export const entryReorderValidator = [ body('entryId').isString().notEmpty(), body('destinationId').isString().notEmpty(), body('order').isIn(['before', 'after', 'insert']), @@ -27,7 +60,7 @@ export const rundownReorderValidator = [ requestValidationFunction, ]; -export const rundownSwapValidator = [ +export const entrySwapValidator = [ body('from').isString().notEmpty(), body('to').isString().notEmpty(), @@ -42,3 +75,5 @@ export const rundownArrayOfIds = [ requestValidationFunction, ]; + +// #endregion operations on rundown entries ======================= diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts index 62d56e9e9..bd867e89d 100644 --- a/apps/server/src/api-data/session/session.service.ts +++ b/apps/server/src/api-data/session/session.service.ts @@ -5,7 +5,7 @@ import { publicDir } from '../../setup/index.js'; import { socket } from '../../adapters/WebsocketAdapter.js'; import { getLastRequest } from '../../api-integration/integration.controller.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 { getTimezoneLabel } from '../../utils/time.js'; import { password, routerPrefix } from '../../externals.js'; diff --git a/apps/server/src/api-data/validation-utils/validationFunction.ts b/apps/server/src/api-data/validation-utils/validationFunction.ts index bc8f8903a..7701349f1 100644 --- a/apps/server/src/api-data/validation-utils/validationFunction.ts +++ b/apps/server/src/api-data/validation-utils/validationFunction.ts @@ -1,6 +1,10 @@ import type { Request, Response, NextFunction } from 'express'; import { param, validationResult } from 'express-validator'; +export const paramsWithId = [param('id').isString().trim().notEmpty(), requestValidationFunction]; + +// #region operations on project rundowns ========================= + /** * Runs validation and any error are sent with status 422 */ @@ -31,4 +35,7 @@ export function requestValidationFunctionWithFile(req: Request, res: Response, n next(); } -export const paramsWithId = [param('id').isString().trim().notEmpty(), requestValidationFunction]; +// #endregion operations on project rundowns ====================== +// #region operations on rundown entries ========================== + +// #endregion operations on rundown entries ======================= diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index f6becf6de..f18ee9e35 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -15,7 +15,7 @@ import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js'; import * as messageService from '../services/message-service/message.service.js'; import { validateMessage, validateTimerMessage } from '../services/message-service/message.utils.js'; -import { runtimeService } from '../services/runtime-service/RuntimeService.js'; +import { runtimeService } from '../services/runtime-service/runtime.service.js'; import { eventStore } from '../stores/EventStore.js'; import * as assert from '../utils/assert.js'; import { parseProperty } from './integration.utils.js'; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 361979638..ef145a051 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -31,12 +31,11 @@ import { getDataProvider } from './classes/data-provider/DataProvider.js'; import { logger } from './classes/Logger.js'; import { populateStyles } from './setup/loadStyles.js'; import { eventStore } from './stores/EventStore.js'; -import { runtimeService } from './services/runtime-service/RuntimeService.js'; +import { runtimeService } from './services/runtime-service/runtime.service.js'; import { RestorePoint, restoreService } from './services/RestoreService.js'; import * as messageService from './services/message-service/message.service.js'; import { populateDemo } from './setup/loadDemo.js'; import { getState } from './stores/runtimeState.js'; -import { initRundown } from './api-data/rundown/rundown.service.js'; import { initialiseProject } from './services/project-service/ProjectService.js'; import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; import { oscServer } from './adapters/OscAdapter.js'; @@ -89,7 +88,11 @@ app.use(`${prefix}/data`, authenticate, appRouter); // router for application da app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations // serve static external files -app.use(`${prefix}/external`, express.static(publicDir.externalDir, { etag: false, lastModified: true })); +app.use( + `${prefix}/external`, + authenticateAndRedirect, + express.static(publicDir.externalDir, { etag: false, lastModified: true }), +); app.use(`${prefix}/external`, (req, res) => { // if the user reaches to the root, we show a 404 res.status(404).send(`${req.originalUrl} not found`); @@ -211,11 +214,6 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb ping: 1, }); - // initialise rundown service - const persistedRundown = getDataProvider().getRundown(); - const persistedCustomFields = getDataProvider().getCustomFields(); - await initRundown(persistedRundown, persistedCustomFields); - // initialise message service messageService.init(eventStore.set, eventStore.get); diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 2e556a6f7..c7c762e1f 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -59,7 +59,9 @@ export function getDataProvider() { getAutomation, setAutomation, getRundown, + getProjectRundowns, mergeIntoData, + deleteRundown, }; } @@ -101,10 +103,9 @@ function getCustomFields(): Readonly { return db.data.customFields; } -async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { - db.data.rundowns[rundownKey] = newData; +async function setRundown(rundownKey: string, newData: Rundown): Promise { + db.data.rundowns[rundownKey] = structuredClone(newData); await persist(); - return db.data.rundowns[rundownKey]; } function getSettings(): Readonly { @@ -147,9 +148,20 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise { - const firstRundown = Object.keys(db.data.rundowns)[0]; - return db.data.rundowns[firstRundown]; +function getRundown(rundownKey: string): Readonly { + if (!(rundownKey in db.data.rundowns)) throw new Error(`Rundown with id: ${rundownKey} not found`); + return db.data.rundowns[rundownKey]; +} + +async function deleteRundown(rundownKey: string): Promise { + if (!(rundownKey in db.data.rundowns)) throw new Error(`Rundown with id: ${rundownKey} not found`); + delete db.data.rundowns[rundownKey]; + await persist(); + return db.data.rundowns; +} + +function getProjectRundowns(): Readonly { + return db.data.rundowns; } async function mergeIntoData(newData: Partial): ReadonlyPromise { diff --git a/apps/server/src/external/demo/app.js b/apps/server/src/external/demo/app.js index 50fb1cfec..0aa59945e 100644 --- a/apps/server/src/external/demo/app.js +++ b/apps/server/src/external/demo/app.js @@ -6,7 +6,7 @@ // Data that the user needs to provide depending on the Ontime URL const isSecure = window.location.protocol === 'https:'; -const userProvidedSocketUrl = `${isSecure ? 'wss' : 'ws'}://${window.location.hostname}${getStageHash()}${getUserPort()}/ws`; +const userProvidedSocketUrl = `${isSecure ? 'wss' : 'ws'}://${window.location.host}${getStageHash()}/ws`; connectSocket(); @@ -142,7 +142,7 @@ function formatObject(data) { * You can likely ignore this in your app * * an url looks like - * https://cloud.getontime.no/stage-hash/external/demo/ + * https://cloud.getontime.no/stage-hash/external/demo/ -> /stage-hash * @returns {string} - The stage hash if the app is running in an ontime stage */ function getStageHash() { @@ -153,16 +153,5 @@ function getStageHash() { const hash = href.split('/'); const stageHash = hash.at(3); - return stageHash || ''; -} - -/** - * Utility to optionally use a URL port - * You can likely hard code this in your app - * - * @returns {string} - The port Ontime server is available at - */ -function getUserPort() { - const port = window.location.port; - return port ? `:${port}` : ''; + return stageHash ? `/${stageHash}` : ''; } diff --git a/apps/server/src/services/app-state-service/AppStateService.ts b/apps/server/src/services/app-state-service/AppStateService.ts index 2c565b5e9..45bfe05d3 100644 --- a/apps/server/src/services/app-state-service/AppStateService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -7,7 +7,8 @@ import { isPath } from '../../utils/fileManagement.js'; import { shouldCrashDev } from '../../utils/development.js'; interface AppState { - lastLoadedProject?: string; + projectName?: string; + rundownId?: string; showWelcomeDialog?: boolean; } @@ -15,24 +16,33 @@ const adapter = new JSONFile(publicFiles.appState); const config = new Low(adapter, {}); export async function isLastLoadedProject(projectName: string): Promise { - const lastLoaded = await getLastLoadedProject(); - return lastLoaded === projectName; + const lastLoaded = await getLastLoaded(); + return lastLoaded?.projectName === projectName; } -export async function getLastLoadedProject(): Promise { +export async function getLastLoaded(): Promise | undefined> { // in test environment, we want to start the demo project if (isTest) return; await config.read(); - return config.data.lastLoadedProject; + return { projectName: config.data.projectName, rundownId: config.data.rundownId }; } -export async function setLastLoadedProject(filename: string): Promise { +export async function setLastLoaded(projectName: string, rundownId?: string): Promise { if (isTest) return; - // eslint-disable-next-line no-unused-labels -- dev code path - DEV: shouldCrashDev(isPath(filename), 'setLastLoadedProject should not be called with a path'); - config.data.lastLoadedProject = filename; + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: shouldCrashDev(isPath(projectName), 'setLastLoaded should not be called with a path'); + + config.data.projectName = projectName; + config.data.rundownId = rundownId; + await config.write(); +} + +export async function setLastLoadedRundown(rundownKey: string): Promise { + if (isTest) return; + + config.data.rundownId = rundownKey; await config.write(); } diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 39fe57237..5297d4073 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -25,12 +25,8 @@ import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { parseDatabaseModel } from '../../api-data/db/db.parser.js'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; -import { - getLastLoadedProject, - isLastLoadedProject, - setLastLoadedProject, -} from '../app-state-service/AppStateService.js'; -import { runtimeService } from '../runtime-service/RuntimeService.js'; +import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js'; +import { runtimeService } from '../runtime-service/runtime.service.js'; import { doesProjectExist, @@ -84,7 +80,7 @@ export async function getCurrentProject(): Promise<{ filename: string; pathToFil * @param projectData * @param fileName file name of the project including the extension */ -async function loadProject(projectData: DatabaseModel, fileName: string) { +async function loadProject(projectData: DatabaseModel, fileName: string, rundownId?: string) { // change LowDB to point to new file await initPersistence(getPathToProject(fileName), projectData); logger.info(LogOrigin.Server, `Loaded project ${fileName}`); @@ -92,13 +88,16 @@ async function loadProject(projectData: DatabaseModel, fileName: string) { // stop the runtime service runtimeService.stop(); - // load the first rundown in the project - const firstRundown = getFirstRundown(projectData.rundowns); + // load the rundown given by key otherwise load the first in the project + const rundown = + rundownId && rundownId in projectData.rundowns + ? projectData.rundowns[rundownId] + : getFirstRundown(projectData.rundowns); - await initRundown(firstRundown, projectData.customFields); + await initRundown(rundown, projectData.customFields, true); // persist the project selection - await setLastLoadedProject(fileName); + await setLastLoaded(fileName, rundown.id); // update the service state currentProjectState = { @@ -150,7 +149,7 @@ async function handleMigratedFile(filePath: string, fileName: string): Promise { // check what was loaded before - const previousProject = await getLastLoadedProject(); + const lastLoaded = await getLastLoaded(); // in normal circumstances we dont have a previous project if it is the first app start - // in which case we want to load a demo project - if (!previousProject) { + // in previousLoaded case we want to load a demo project + if (!lastLoaded?.projectName) { return loadDemoProject(); } + try { - const projectName = await loadProjectFile(previousProject); + const projectName = await loadProjectFile(lastLoaded.projectName, lastLoaded.rundownId); return projectName; } catch (error) { // if we are here, most likely the json parsing failed and the file is corrupt - logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`); + logger.warning( + LogOrigin.Server, + `Unable to load previous project ${lastLoaded.projectName}: ${getErrorMessage(error)}`, + ); try { - const pathToFile = getPathToProject(previousProject); - await moveCorruptFile(pathToFile, previousProject); + const pathToFile = getPathToProject(lastLoaded.projectName); + await moveCorruptFile(pathToFile, lastLoaded.projectName); } catch (_) { /* while we have to catch the error, we dont need to handle it */ } @@ -191,7 +194,7 @@ export async function initialiseProject(): Promise { * Loads a data from a file into the runtime * @param fileName file name of the project including the extension */ -export async function loadProjectFile(fileName: string): Promise { +export async function loadProjectFile(fileName: string, rundownId?: string): Promise { const filePath = doesProjectExist(fileName); if (filePath === null) { throw new Error('Project file not found'); @@ -202,16 +205,15 @@ export async function loadProjectFile(fileName: string): Promise { const result = parseDatabaseModel(fileData); let parsedFileName = fileName; - if (result.errors.length > 0) { - logger.warning(LogOrigin.Server, 'Project loaded with errors'); - parsedFileName = await handleCorruptedFile(filePath, fileName); - } if (result.migrated) { logger.warning(LogOrigin.Server, 'The imported project is migrate, the original file has been backed up'); - parsedFileName = await handleMigratedFile(filePath, fileName); + parsedFileName = await handleMigratedFile(filePath, parsedFileName); + } else if (result.errors.length > 0) { + logger.warning(LogOrigin.Server, 'Project loaded with errors'); + parsedFileName = await handleCorruptedFile(filePath, parsedFileName); } - const projectName = await loadProject(result.data, parsedFileName); + const projectName = await loadProject(result.data, parsedFileName, rundownId); return projectName; } @@ -220,11 +222,11 @@ export async function loadProjectFile(fileName: string): Promise { */ export async function getProjectList(): Promise { const files = await getProjectFiles(); - const lastLoadedProject = await getLastLoadedProject(); + const lastLoaded = await getLastLoaded(); return { files, - lastLoadedProject: lastLoadedProject ? removeFileExtension(lastLoadedProject) : '', + lastLoadedProject: lastLoaded?.projectName ? removeFileExtension(lastLoaded?.projectName) : '', }; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/runtime.service.ts similarity index 99% rename from apps/server/src/services/runtime-service/RuntimeService.ts rename to apps/server/src/services/runtime-service/runtime.service.ts index 2982ed989..2f49eb824 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -37,7 +37,7 @@ import { getShouldOffsetUpdate, getShouldTimerUpdate, isNewSecond, -} from './rundownService.utils.js'; +} from './runtime.utils.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; /** diff --git a/apps/server/src/services/runtime-service/rundownService.utils.ts b/apps/server/src/services/runtime-service/runtime.utils.ts similarity index 100% rename from apps/server/src/services/runtime-service/rundownService.utils.ts rename to apps/server/src/services/runtime-service/runtime.utils.ts diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index c552addb4..853f219a9 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -247,7 +247,6 @@ export async function handleInitialConnection( clientSecret: ClientSecret, sheetId: string, ): Promise<{ verification_url: string; user_code: string }> { - // TODO: check if the clientSecret has changed currentClientSecret = clientSecret; // we know there is an ongoing process if there is a timeout for cleanup @@ -346,9 +345,14 @@ export async function upload(sheetId: string, options: ImportMap) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); } - const { rundownMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options); + const { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options); const rundown = getCurrentRundown(); - const titleRow = Object.values(rundownMetadata)[0]['row']; + + const titleMetadata = Object.values(sheetMetadata)[0]; + if (titleMetadata === undefined) { + throw new Error(`Sheet read failed: failed to find title row`); + } + const titleRow = titleMetadata['row']; const updateRundown = Array(); // we can't delete the last unfrozen row so we create an empty one @@ -385,7 +389,7 @@ export async function upload(sheetId: string, options: ImportMap) { // update the corresponding row with event data rundown.order.forEach((entryId, index) => { const entry = rundown.entries[entryId]; - return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)); + return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); }); const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ diff --git a/apps/server/src/user/logo/ontime-logo.png b/apps/server/src/user/logo/ontime-logo.png new file mode 100644 index 000000000..91ffece1e Binary files /dev/null and b/apps/server/src/user/logo/ontime-logo.png differ diff --git a/apps/server/src/utils/__tests__/coerceType.test.ts b/apps/server/src/utils/__tests__/coerceType.test.ts index b56ddfdad..51086580e 100644 --- a/apps/server/src/utils/__tests__/coerceType.test.ts +++ b/apps/server/src/utils/__tests__/coerceType.test.ts @@ -1,4 +1,4 @@ -import { coerceColour, coerceEnum } from '../coerceType.js'; +import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../coerceType.js'; describe('parses a colour string that is', () => { it('valid hex', () => { @@ -44,3 +44,145 @@ describe('match a string to an enum that is', () => { expect(() => coerceEnum(123, testEnum)).toThrow(); }); }); + +describe('coerce unknown value to a number', () => { + it('throws on null', () => { + expect(() => coerceNumber(null)).toThrowError('Invalid value received'); + }); + + it('throws on NaN', () => { + expect(() => coerceNumber('abc')).toThrowError('Invalid value received'); + }); + + it('throws on undefined', () => { + expect(() => coerceNumber(undefined)).toThrowError('Invalid value received'); + }); + + it('throws on object', () => { + expect(() => coerceNumber({ test: 'object' })).toThrowError('Invalid value received'); + }); + + it('throws on array', () => { + expect(() => coerceNumber([1, 2, 3])).toThrowError('Invalid value received'); + }); + + it('casts string to number', () => { + expect(coerceNumber('123')).toStrictEqual(123); + }); + + it('handles white space', () => { + expect(coerceNumber(' 9 ')).toStrictEqual(9); + }); + + it('handles normal numbers', () => { + expect(coerceNumber(5)).toStrictEqual(5); + }); + + it('handles booleans', () => { + expect(coerceNumber(true)).toStrictEqual(1); + expect(coerceNumber(false)).toStrictEqual(0); + }); +}); + +describe('coerce unknown value to a string', () => { + it('throws on null', () => { + expect(() => coerceString(null)).toThrowError('Invalid value received'); + }); + + it('throws on undefined', () => { + expect(() => coerceString(undefined)).toThrowError('Invalid value received'); + }); + + it('throws on objects', () => { + expect(() => coerceString({ test: 'object' })).toThrowError('Invalid value received'); + }); + + it('throws on array', () => { + expect(() => coerceString([1, 2, 3])).toThrowError('Invalid value received'); + }); + + it('casts number to string', () => { + expect(coerceString(123)).toStrictEqual('123'); + }); + + it('handles normal strings', () => { + expect(coerceString('abcd')).toStrictEqual('abcd'); + }); + + it('handles booleans', () => { + expect(coerceString(true)).toStrictEqual('true'); + expect(coerceString(false)).toStrictEqual('false'); + }); +}); + +describe('coerce unknown value to a boolean', () => { + it('throws on null', () => { + expect(() => coerceBoolean(null)).toThrowError('Invalid value received'); + }); + + it('throws on undefined', () => { + expect(() => coerceBoolean(undefined)).toThrowError('Invalid value received'); + }); + + it('throws on objects', () => { + expect(() => coerceBoolean({ test: 'object' })).toThrowError('Invalid value received'); + }); + + it('throws on array', () => { + expect(() => coerceBoolean([1, 2, 3])).toThrowError('Invalid value received'); + }); + + test('true strings', () => { + expect(coerceBoolean('true')).toStrictEqual(true); + expect(coerceBoolean('1')).toStrictEqual(true); + expect(coerceBoolean('yes')).toStrictEqual(true); + }); + + test('false strings', () => { + expect(coerceBoolean('false')).toStrictEqual(false); + expect(coerceBoolean('0')).toStrictEqual(false); + expect(coerceBoolean('no')).toStrictEqual(false); + expect(coerceBoolean('')).toStrictEqual(false); + }); + + test('true numbers', () => { + expect(coerceBoolean(1)).toStrictEqual(true); + expect(coerceBoolean(2)).toStrictEqual(true); + expect(coerceBoolean(100000)).toStrictEqual(true); + }); + + test.todo('false numbers', () => { + expect(coerceBoolean(0)).toStrictEqual(false); + expect(coerceBoolean(-1)).toStrictEqual(false); + expect(coerceBoolean(-10000)).toStrictEqual(false); + }); + + test('booleans', () => { + expect(coerceBoolean(true)).toStrictEqual(true); + expect(coerceBoolean(false)).toStrictEqual(false); + }); +}); + +describe('coerce unknown value to a colour', () => { + it('throws on all non strings', () => { + expect(() => coerceColour(null)).toThrowError('Invalid colour value received'); + expect(() => coerceColour(undefined)).toThrowError('Invalid colour value received'); + expect(() => coerceColour({ test: 'object' })).toThrowError('Invalid colour value received'); + expect(() => coerceColour([1, 2, 3])).toThrowError('Invalid colour value received'); + expect(() => coerceColour(true)).toThrowError('Invalid colour value received'); + expect(() => coerceColour(false)).toThrowError('Invalid colour value received'); + }); + + test('hex values', () => { + expect(() => coerceColour('#1')).toThrowError('Invalid hex colour received'); + expect(coerceColour('#AAA')).toStrictEqual('#aaa'); + expect(coerceColour('#FF3366')).toStrictEqual('#ff3366'); + }); + + test('css values', () => { + expect(() => coerceColour('grøn')).toThrowError('Invalid colour name received'); + expect(coerceColour('')).toStrictEqual(''); + expect(coerceColour('aliceblue')).toStrictEqual('aliceblue'); + expect(coerceColour('darkkhaki')).toStrictEqual('darkkhaki'); + }); +}); diff --git a/apps/server/src/utils/coerceType.ts b/apps/server/src/utils/coerceType.ts index be9c68f71..9103a33dc 100644 --- a/apps/server/src/utils/coerceType.ts +++ b/apps/server/src/utils/coerceType.ts @@ -13,7 +13,6 @@ export function coerceEnum(value: unknown, list: object): T { return value as T; } -//TODO: write tests /** * @description Converts a value to a string if possible, throws otherwise * @param {unknown} value - Value to be converted to a string. @@ -21,13 +20,12 @@ export function coerceEnum(value: unknown, list: object): T { * @throws {Error} Throws an error if the value is null or undefined. */ export function coerceString(value: unknown): string { - if (value == null) { + if (value == null || typeof value === 'object') { throw new Error('Invalid value received'); } return String(value); } -//TODO: write tests /** * @description Converts a value to a boolean if possible, throws otherwise * @param {unknown} value - Value to be converted to a boolean. @@ -35,7 +33,7 @@ export function coerceString(value: unknown): string { * @throws {Error} Throws an error if the value is null or undefined. */ export function coerceBoolean(value: unknown): boolean { - if (value == null) { + if (value === undefined || typeof value === 'object') { throw new Error('Invalid value received'); } if (typeof value === 'string') { @@ -57,7 +55,6 @@ export function coerceBoolean(value: unknown): boolean { return Boolean(value); } -//TODO: write tests /** * @description Converts a value to a number if possible, throws otherwise * @param {unknown} value - Value to be converted to a number. diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index 3f203335c..b59cf66ae 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -7,8 +7,4 @@ test('cuesheet displays events', async ({ page }) => { await expect(page.getByRole('row', { name: 'Afternoon break' })).toBeVisible(); await expect(page.locator('#cuesheet')).toBeVisible(); - - // there should be 16 rows in the table (same as the amount of events in the rundown) - await expect(page.getByTestId('cuesheet-event')).toHaveCount(14); - await expect(page.getByTestId('cuesheet-group')).toHaveCount(2); }); diff --git a/package.json b/package.json index db29ae20d..fb01fd28f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "4.0.0-alpha.4", + "version": "4.0.0-alpha.5", "description": "Time keeping for live events", "keywords": [ "ontime", diff --git a/packages/types/src/definitions/core/Rundown.type.ts b/packages/types/src/definitions/core/Rundown.type.ts index 46d053ad1..d78cfd0bf 100644 --- a/packages/types/src/definitions/core/Rundown.type.ts +++ b/packages/types/src/definitions/core/Rundown.type.ts @@ -6,7 +6,7 @@ type RundownId = string; export type ProjectRundowns = Record; export type Rundown = { - id: string; + id: RundownId; title: string; order: EntryId[]; flatOrder: EntryId[]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2f65df8e1..e993da06e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -16,7 +16,11 @@ export { type TimeField, SupportedEntry as SupportedEntry, } from './definitions/core/OntimeEntry.js'; -export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js'; +export type { + RundownEntries, + Rundown, + ProjectRundowns, +} from './definitions/core/Rundown.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; @@ -80,6 +84,7 @@ export type { export type { EventPostPayload, PatchWithId, + ProjectRundown, ProjectRundownsList, TransientEventPayload, } from './api/rundown-controller/BackendResponse.type.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc6527895..05b5eacf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: react-simple-code-editor: specifier: ^0.14.1 version: 0.14.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react-virtuoso: + specifier: ^4.14.0 + version: 4.14.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -4168,6 +4171,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + react-virtuoso@4.14.0: + resolution: {integrity: sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.1.1: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -6790,7 +6799,7 @@ snapshots: ejs: 3.1.10 electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.0.18) electron-publish: 24.13.1 - form-data: 4.0.3 + form-data: 4.0.4 fs-extra: 10.1.0 hosted-git-info: 4.1.0 is-ci: 3.0.1 @@ -8749,7 +8758,7 @@ snapshots: decimal.js: 10.5.0 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.3 + form-data: 4.0.4 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -9382,6 +9391,11 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + react-virtuoso@4.14.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react@19.1.1: {} read-binary-file-arch@1.0.6:
} + {!hideIndexColumn && ( + + # +
} - {!hideIndexColumn && ( - - # -
- {delayTime} -
{delayTime}
@@ -94,7 +96,7 @@ export default function EventRow({ onClick={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; - openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent, event.flag); + openMenu({ x: rect.x, y: yPos }, id, SupportedEntry.Event, rowIndex, parent, flag); }} > @@ -106,26 +108,24 @@ export default function EventRow({ {eventIndex} - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
; + table: Table; } export default function MilestoneRow({ @@ -25,10 +27,11 @@ export default function MilestoneRow({ isPast, parentBgColour, parentId, - rowBgColour, + colour, rowId, rowIndex, table, + ...virtuosoProps }: MilestoneRowProps) { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { cuesheetMode: AppMode.Edit, @@ -37,6 +40,18 @@ export default function MilestoneRow({ const openMenu = useCuesheetTableMenu((store) => store.openMenu); + let rowBgColour: string | undefined; + if (colour) { + // the colour is user defined and might be invalid + const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor); + if (accessibleBackgroundColor !== null) { + rowBgColour = colourToHex({ + ...accessibleBackgroundColor, + alpha: accessibleBackgroundColor.alpha * 0.25, + }); + } + } + return (
@@ -79,9 +95,9 @@ export default function MilestoneRow({ style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)`, backgroundColor: rowBgColour, + opacity: canRender ? 1 : 0.4, }} tabIndex={-1} - role='cell' > {canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}