Compare commits

..

2 Commits

Author SHA1 Message Date
Claude 7a55aab400 test(timer): assert elapsed stays frozen while paused over midnight
Make the midnight pause test's intent explicit: elapsed is active time
since start and must not advance during a pause (even one crossing
midnight). Add a frozen-elapsed assertion while paused and keep
pausedDuration - the corrupted pause count - as the headline assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-07-14 10:47:40 +00:00
Claude 7b4ebbf7f5 test(timer): expose pause-over-midnight duration bug
Pause is tracked as pausedAt (TimeOfDay, ms since local midnight) and
paused duration is derived via the naive `clock - pausedAt`. When a pause
spans midnight the clock has wrapped to a small value while pausedAt is
still large, so the subtraction goes negative and every paused-duration
result is corrupted (runtimeState.start resume accumulation, and
getExpectedFinish/getCurrent/getRuntimeOffset in timerUtils).

Add two currently-failing tests that reproduce this:
- runtimeState: full start/pause/resume cycle where the pause crosses
  midnight, asserting pausedDuration and elapsed exclude the pause.
- timerUtils.getRuntimeOffset: over-midnight variant of the paused-offset
  case (the site carrying the "brakes when crossing midnight" TODO).

Both fail today (report ~ -86,100,000 instead of the real 5-minute pause)
and will pass once the pause math adopts the wrap-aware primitives
(timeCore.elapsedTime / epoch-based tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-07-14 10:41:50 +00:00
127 changed files with 946 additions and 3747 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.11.0", "version": "4.10.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.11.0", "version": "4.10.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
-10
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
// #region operations on rundown entries ========================== // #region operations on rundown entries ==========================
@@ -20,7 +20,7 @@
.wide { .wide {
top: 4vh; top: 4vh;
min-width: min(1280px, 96vw); min-width: min(1280px, 96vw);
max-width: min(1800px, 98vw); max-width: min(1600px, 96vw);
height: 88vh; height: 88vh;
max-height: 88vh; max-height: 88vh;
display: flex; display: flex;
@@ -11,7 +11,6 @@ import {
} from '../../../stores/savedViewParams'; } from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton'; import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -106,20 +105,16 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
{isCustomised && ( {isCustomised && (
<span className={style.trailing}> <span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' /> <span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip <IconButton
text='Reset to default' variant='ghosted-white'
render={ size='small'
<IconButton className={style.clear}
variant='ghosted-white' aria-label='Clear saved view settings'
size='small' title='Clear saved view settings'
className={style.clear} onClick={clearViewSettings}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
> >
<IoCloseOutline /> <IoCloseOutline />
</Tooltip> </IconButton>
</span> </span>
)} )}
</NavigationMenuItem> </NavigationMenuItem>
@@ -1,43 +1,20 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color); color: $white-10;
.empty { .empty {
display: block; display: block;
width: min(100%, 14rem); width: min(100%, 24rem);
margin: 0 auto -1.5rem; margin-inline: auto;
opacity: 0.6; opacity: 0.8;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 400; font-weight: 600;
font-size: clamp(1rem, 1.55vw, 1.5rem); font-size: 2em;
line-height: 1.35; max-width: min(100%, 600px);
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
} }
} }
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) { export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return ( return (
<div <div className={cx([style.emptyContainer, className])} style={injectedStyles}>
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100dvh; height: 100vh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding-top: 5rem;
padding-block: 5rem;
} }
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} /> <Empty text={text} injectedStyles={injectedStyles} />
</div> </div>
); );
} }
@@ -15,4 +15,9 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} /> <Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -6,7 +6,6 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
@@ -28,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions); const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -38,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {}; const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }), queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? placeholder, status, isFetching, isError, isLoadingError, refetch }; return { data: data ?? placeholder, status, isFetching, isError, refetch };
} }
@@ -6,14 +6,14 @@ import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData'; import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }), queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch }; return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
} }
export function useUpdateProjectData() { export function useUpdateProjectData() {
@@ -32,7 +32,7 @@ export default function useRundown() {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }), queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -50,14 +50,14 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]); }, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, isLoadingError, rundownMetadata }; return { data, status, rundownMetadata };
} }
/** /**
@@ -65,7 +65,7 @@ export function useRundownWithMetadata() {
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status, isLoadingError } = useRundown(); const { data, status } = useRundown();
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { if (data.revision === -1) {
@@ -74,15 +74,15 @@ export function useFlatRundown() {
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined); return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]); }, [data]);
return { data: flatRundown, rundownId: data.id, status, isLoadingError }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status, isLoadingError }; return { data: rundownWithMetadata, status };
} }
/** /**
@@ -127,7 +127,7 @@ export function useRundownAuxData() {
export function useRundownById(rundownId: string | null | undefined) { export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId); const enabled = Boolean(rundownId);
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''), queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }), queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled, enabled,
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
@@ -11,7 +11,6 @@ export type RundownSource = {
rundown: Rundown; rundown: Rundown;
flatRundown: ExtendedEntry[]; flatRundown: ExtendedEntry[];
status: string; status: string;
isLoadingError: boolean;
selectedEventId: EntryId | null; selectedEventId: EntryId | null;
}; };
@@ -36,7 +35,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId; const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId(); const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null; const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status, isLoadingError } = useRundownById(rundownId); const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo( const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId), () => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown], [effectiveSelectedEventId, rundown],
@@ -48,9 +47,8 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
rundown, rundown,
flatRundown, flatRundown,
status, status,
isLoadingError,
selectedEventId: effectiveSelectedEventId, selectedEventId: effectiveSelectedEventId,
}), }),
[effectiveSelectedEventId, flatRundown, isLoadingError, rundown, rundownId, status], [effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
); );
} }
@@ -6,7 +6,7 @@ import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings'; import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }), queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -22,5 +22,5 @@ export default function useSettings() {
}, },
}); });
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, isLoadingError, refetch }; return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
} }
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status, isLoadingError } = useQuery({ const { data, status } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }), queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
}, },
}); });
return { data: data ?? viewsSettingsPlaceholder, status, isLoadingError, mutateAsync }; return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
} }
+7 -13
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
MaybeString, MaybeString,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -59,15 +60,9 @@ export type EventOptions = Partial<{
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
linkPrevious: boolean; linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/** /**
* Gather utilities for actions on entries in the loaded rundown. * Gather utilities for actions on entries in the loaded rundown.
*/ */
@@ -125,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => { onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey(); const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
@@ -154,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown( addToRundown(
newRundown, newRundown,
optimisticEntry, optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId, afterId,
entry.before ?? null, parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
); );
queryClient.setQueryData<Rundown>(queryKey, newRundown); queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -199,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() }; const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types // handle adding options that concern all event types
if (options?.after) { if (options?.after) {
@@ -211,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -290,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
@@ -1063,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/** /**
* Utility to create an optimistic entry for immediate cache insertion * Utility to create an optimistic entry for immediate cache insertion
*/ */
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry { function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload; const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id; const id = entryData.id;
let parent: EntryId | null = null; let parent: EntryId | null = null;
-11
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined), pause: () => sendSocket('pause', undefined),
}; };
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
dayInMs,
formatFromMillis, formatFromMillis,
getExpectedEnd,
getExpectedStart, getExpectedStart,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed; return elapsed;
} }
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null * @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state, ...state,
}, },
); );
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay; const plannedEnd = event.timeStart + event.duration + event.delay;
return { return {
expectedStart, expectedStart,
timeToStart: expectedStart - state.clock, timeToStart: expectedStart - state.clock,
expectedEnd, expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd, plannedEnd,
}; };
} }
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
} }
} }
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx'); return file?.name.endsWith('.xlsx');
} }
@@ -293,9 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br /> or to change properties of Ontime itself.
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info> </Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
@@ -342,17 +341,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label> </label>
<label> <label>
Address Address
<TemplateInput <Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label> </label>
<label> <label>
Arguments Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' /> <TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
<div> <div>
@@ -382,7 +376,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.httpSection}> <div className={style.httpSection}>
<label> <label>
Target URL Target URL
<TemplateInput <Input
{...register(`outputs.${index}.url`, { {...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
pattern: { pattern: {
@@ -390,7 +384,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
message: 'HTTP messages should target http:// or https://', message: 'HTTP messages should target http:// or https://',
}, },
})} })}
value={output.url}
fluid fluid
placeholder='http://127.0.0.1/start/1' placeholder='http://127.0.0.1/start/1'
/> />
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' }, { value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' }, { value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message' }, { value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message' }, { value: 'message-secondary', label: 'Secondary Message: source' },
]} ]}
/> />
<Panel.Error>{rowErrors?.action?.message}</Panel.Error> <Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
<> <>
<label> <label>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label>
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <label>
<label> Timer secondary source
Text (leave empty for no change) <Select<SecondarySource | 'null' | null>
<TemplateInput onValueChange={(value) => {
{...register(`outputs.${index}.text`)} // null -> no selection
value={watch(`outputs.${index}.text`) ?? ''} if (value === null) return;
fluid // 'null' -> clear the secondary source
placeholder='eg: Next up: keynote' if (value === 'null') {
/> setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> return;
</label> }
<label> setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
Timer secondary source }}
<Select<SecondarySource | 'no-change' | 'null' | null> value={watch(`outputs.${index}.secondarySource`)}
onValueChange={(value) => { options={[
// null -> no selection { value: null, label: 'Select secondary source' },
if (value === null) return; { value: 'aux1', label: 'Auxiliary timer 1' },
// no-change -> leave the current secondary source untouched { value: 'aux2', label: 'Auxiliary timer 2' },
if (value === 'no-change') { { value: 'aux3', label: 'Auxiliary timer 3' },
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true }); { value: 'secondary', label: 'Secondary' },
return; { value: 'null', label: 'None' }, // allow the user to clear the secondary source
} ]}
// 'null' -> clear the secondary source />
if (value === 'null') { <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true }); </label>
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody> <tbody>
{!showForm && triggers.length === 0 && ( {!showForm && triggers.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
label='Create a trigger to run an automation' label='Create an automation to attach triggers to'
handleClick={canAdd ? () => setShowForm(true) : undefined} handleClick={canAdd ? () => setShowForm(true) : undefined}
/> />
)} )}
@@ -1,67 +1,29 @@
.inputShell { .wrapper {
position: relative; position: relative;
} }
.fluid { .suggestions {
width: 100%;
}
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250; background: $gray-1250;
color: $label-gray; color: $ui-white;
}
.list { position: absolute;
box-sizing: border-box; top: 100%;
max-height: min(20rem, var(--available-height)); left: 0;
overflow-y: auto; width: 100%;
overscroll-behavior: contain; margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem; padding-block: 0.25rem;
outline: 0;
}
.item { max-height: 200px;
box-sizing: border-box; overflow-y: auto;
padding: 0.25rem 0.5rem; color: $label-gray;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] { li {
padding: 0.25rem;
}
li:hover {
color: $ui-white; color: $ui-white;
background: $blue-700; background: $blue-700;
} }
} }
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,258 +1,69 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'; import { mergeRefs, useClickOutside } from '@mantine/hooks';
import type { ChangeEvent, ReactNode, Ref } from 'react'; import { forwardRef, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Button from '../../../../../common/components/buttons/Button'; import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { cx } from '../../../../../common/utils/styleUtils'; import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss'; import style from './TemplateInput.module.scss';
interface TemplateInputProps extends Omit<InputProps, 'value'> { interface TemplateInputProps extends InputProps {}
ref?: Ref<HTMLInputElement>;
value?: string;
}
interface TemplateEditorModalProps { const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
autocompleteList: string[]; const { value, onChange, ...rest } = props;
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const inputRef = useRef<HTMLInputElement | null>(null); const localRef = useClickOutside(() => setShowSuggestions(false));
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => { const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data); return makeAutoCompleteList(data);
}, [data]); }, [data]);
const updateInputValue = useCallback( const [inputValue, setInputValue] = useState(value || '');
(nextValue: string) => { const [suggestions, setSuggestions] = useState<string[]>([]);
setInputValue(nextValue); const [showSuggestions, setShowSuggestions] = useState(false);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue); const updateSuggestions = (value: string) => {
const { setCursorForValue } = autocomplete; const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
}; };
const closeExpandedEditor = () => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsExpanded(false); setInputValue(event.target.value);
};
const saveExpandedEditor = (nextValue: string) => { if (event.target.value.endsWith('{')) {
updateInputValue(nextValue); setShowSuggestions(true);
autocomplete.setCursorForValue(nextValue, nextValue.length); setSuggestions(updateSuggestions(event.target.value));
setIsExpanded(false); } else if (event.target.value === '' || event.target.value.endsWith('}}')) {
}; setShowSuggestions(false);
} else if (showSuggestions) {
return ( setSuggestions(updateSuggestions(event.target.value));
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
}
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
} }
setDraftValue(value); onChange?.(event);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
}; };
const handleSave = () => { const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false); setShowSuggestions(false);
onSave(draftValue);
}; };
return ( return (
<Modal <div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
isOpen={isOpen} <Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
onClose={handleClose} {showSuggestions && suggestions.length > 0 && (
showBackdrop <ul className={style.suggestions}>
showCloseButton {suggestions.map((suggestion) => (
title='Edit template' <li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
bodyElements={ {suggestion}
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}> </li>
<BaseAutocomplete.Input ))}
autoFocus </ul>
className={style.expandedEditor} )}
onClick={autocomplete.updateCursor} </div>
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
); );
} });
function TemplateSuggestionPopup() { export default TemplateInput;
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils'; import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => { describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => { it('should return a partial string needed for autocomplete', () => {
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe(''); expect(matchRemaining('test', 'test')).toBe('');
}); });
}); });
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}', '{{eventNext.delay}}',
]; ];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [ const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`, `{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`, `{{auxtimer${index}.duration}}`,
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext, ...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1), ...staticAuxProperties(1),
...staticAuxProperties(2), ...staticAuxProperties(2),
...staticAuxProperties(3), ...staticAuxProperties(3),
]; ];
} }
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/** /**
* Returns the partial string b needed to autocomplete string a * Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st' * @example matchRemaining('te', 'test') -> 'st'
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
return ''; return '';
} }
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/** /**
* Selects the last unclosed starting template before the cursor. * Selects the last starting template in a string
*/ */
export function selectActiveTemplate(text: string, cursorIndex = text.length) { export function selectFromLastTemplate(text: string) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? ''; const lastBraceIndex = text.lastIndexOf('{{');
} if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
} }
return '';
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
} }
@@ -1,72 +0,0 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -25,13 +25,6 @@
margin-top: 1rem; margin-top: 1rem;
} }
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid { .sourceGrid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,6 +1,4 @@
import type { import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse, SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata, SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions, SpreadsheetWorksheetOptions,
@@ -8,7 +6,6 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5'; import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { import {
getWorksheetMetadata as getExcelWorksheetMetadata, getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -25,7 +22,7 @@ import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor'; import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -38,7 +35,6 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource = type ActiveSource =
| { | {
kind: 'excel'; kind: 'excel';
fileName: string;
worksheetNames: string[]; worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean; closedByUser: boolean;
@@ -57,11 +53,9 @@ export default function SourcesPanel() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none'); const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport(); const { importRundown } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -80,7 +74,6 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload); const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({ setActiveSource({
kind: 'excel', kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets, worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata, initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false, closedByUser: false,
@@ -104,7 +97,6 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setActiveSource(null); setActiveSource(null);
setError(''); setError('');
setCompletedRundownTitle('');
}; };
const openGSheetFlow = () => { const openGSheetFlow = () => {
@@ -127,41 +119,29 @@ export default function SourcesPanel() {
} }
}; };
const handleFinished = (rundownTitle: string) => { const handleFinished = () => {
setImportFlow('finished'); setImportFlow('finished');
setHasFile('none'); setHasFile('none');
setActiveSource(null); setActiveSource(null);
setError(''); setError('');
setCompletedRundownTitle(rundownTitle);
}; };
const handleApplyImport = async ( const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished(title);
return;
}
if (!currentRundown) { if (!currentRundown) {
throw new Error('No current rundown loaded'); throw new Error('No current rundown loaded');
} }
// override or merge into the current rundown; merge uses providedFields to know which columns await importRundown(
// the sheet supplied so matched events keep the rest (e.g. automations) {
await applyImport({ [currentRundown.id]: {
mode, ...preview.rundown,
targetRundownId: currentRundown.id, id: currentRundown.id,
rundown: preview.rundown, title: currentRundown.title,
customFields: preview.customFields, },
providedFields, },
}); preview.customFields,
handleFinished(currentRundown.title); );
handleFinished();
}; };
const loadWorksheetMetadata = useCallback( const loadWorksheetMetadata = useCallback(
@@ -228,13 +208,6 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel'; if (activeSource.kind === 'excel') return 'excel';
return `gsheet:${activeSource.sheetId}`; return `gsheet:${activeSource.sheetId}`;
})(); })();
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
// for Excel, or the document title for Google Sheets
const spreadsheetName = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
return activeSource.title;
})();
return ( return (
<Panel.Section> <Panel.Section>
@@ -294,20 +267,11 @@ export default function SourcesPanel() {
{showCompleted && ( {showCompleted && (
<div className={style.finishSection}> <div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span> <span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}> <div className={style.finishTitle}>Spreadsheet data applied.</div>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}. <div className={style.finishDescription}>You can close this flow or start another import.</div>
</div> <Button variant='subtle-white' onClick={resetFlow}>
<div className={style.finishDescription}> Reset flow
Review the imported rundown in the editor or start another import. </Button>
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div> </div>
)} )}
{isGSheetFlow && ( {isGSheetFlow && (
@@ -323,7 +287,6 @@ export default function SourcesPanel() {
bodyElements={ bodyElements={
<SheetImportEditor <SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'} sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []} worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null} initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata} loadMetadata={loadWorksheetMetadata}
@@ -1,55 +0,0 @@
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../../../../common/components/buttons/Button';
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
interface ApplyImportButtonProps {
preview: SpreadsheetPreviewResponse | null;
mode: RundownImportMode;
disabled: boolean;
loading: boolean;
onApply: () => void;
}
/**
* Apply action for the spreadsheet import.
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
* Requires a second click to confirm when applying would stop a running playback.
*/
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
const playback = usePlayback();
const loadedEventId = useSelectedEventId();
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
const willLoadedEventBeOverriden = !(
loadedEntry !== undefined &&
isOntimeEvent(loadedEntry) &&
isPlayableEvent(loadedEntry)
);
// applying stops playback when creating a new rundown, or when the playing event does not survive
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
// two-step confirmation before applying an import that stops playback
const [confirmStop, setConfirmStop] = useState(false);
useEffect(() => {
setConfirmStop(false);
}, [mode, preview]);
const handleClick = () => {
if (willStopPlayback && !confirmStop) {
setConfirmStop(true);
return;
}
onApply();
};
return (
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
</Button>
);
}
@@ -33,11 +33,6 @@
white-space: nowrap; white-space: nowrap;
} }
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger { .addColumnTrigger {
justify-content: center; justify-content: center;
white-space: nowrap; white-space: nowrap;
@@ -92,10 +87,6 @@
font-weight: 600; font-weight: 600;
} }
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle { .mappingPaneTitle {
align-self: center; align-self: center;
} }
@@ -1,20 +1,10 @@
import type { import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import type { ImportMap } from 'ontime-utils';
import { useMemo } from 'react'; import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
import Button from '../../../../../../common/components/buttons/Button'; import Button from '../../../../../../common/components/buttons/Button';
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select'; import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils'; import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable'; import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane'; import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm'; import { useSheetImportForm } from './useSheetImportForm';
@@ -23,46 +13,17 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps { interface SheetImportEditorProps {
sourceKey: string; sourceKey: string;
defaultRundownName: string;
worksheetNames: string[]; worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null; initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>; loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>; previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: ( onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onCancel: () => void; onCancel: () => void;
onExport?: (importMap: ImportMap) => Promise<void>; onExport?: (importMap: ImportMap) => Promise<void>;
} }
const importModeOptions: Array<{
value: RundownImportMode;
label: string;
description: string;
}> = [
{
value: 'override',
label: 'Replace current rundown',
description: 'Spreadsheet data completely replaces current rundown',
},
{
value: 'merge',
label: 'Merge with current rundown',
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
},
{
value: 'new',
label: 'New rundown',
description: 'Create a new rundown to import the data into. Loads the new rundown',
},
];
export default function SheetImportEditor({ export default function SheetImportEditor({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -88,16 +49,11 @@ export default function SheetImportEditor({
isBusy, isBusy,
canPreview, canPreview,
displayError, displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit, handlePreviewSubmit,
handleExportSubmit, handleExportSubmit,
handleApply, handleApply,
} = useSheetImportForm({ } = useSheetImportForm({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -106,19 +62,6 @@ export default function SheetImportEditor({
onExport, onExport,
}); });
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
const importModeItems = useMemo<DropdownMenuOption[]>(
() =>
importModeOptions.map((option) => ({
type: 'item',
label: option.label,
description: option.description,
icon: importMode === option.value ? IoCheckmark : undefined,
onClick: () => setImportMode(option.value),
})),
[importMode, setImportMode],
);
return ( return (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}> <Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}> <Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -132,16 +75,7 @@ export default function SheetImportEditor({
} }
/> />
</label> </label>
{toolbarStatus && ( {toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
</Panel.InlineElements> </Panel.InlineElements>
<div className={style.editorBody}> <div className={style.editorBody}>
@@ -159,19 +93,13 @@ export default function SheetImportEditor({
<section className={style.previewPane}> <section className={style.previewPane}>
<div className={style.previewPaneHeader}> <div className={style.previewPaneHeader}>
<div className={style.previewPaneHeading}> <span className={style.previewPaneTitle}>Import preview</span>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div> </div>
<div className={style.tableShell}> <div className={style.tableShell}>
<PreviewTable <PreviewTable
preview={state.preview} preview={state.preview}
columnLabels={columnLabels} columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata} isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders} worksheetHeaders={worksheetHeaders}
/> />
</div> </div>
@@ -179,63 +107,33 @@ export default function SheetImportEditor({
</div> </div>
{displayError && <Panel.Error>{displayError}</Panel.Error>} {displayError && <Panel.Error>{displayError}</Panel.Error>}
{importMode === 'merge' && !isIdColumnMapped(values) && ( <Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Panel.Description tone='warning'> <Button onClick={onCancel} disabled={isBusy}>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export Cancel
your rundown to a spreadsheet first to keep its IDs. </Button>
</Panel.Description> {onExport && (
)} <Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}> <IoArrowUpOutline />
<Panel.InlineElements wrap='wrap'> Export
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>Import mode</span>
<DropdownMenu
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
items={importModeItems}
>
{selectedImportMode.label}
<IoChevronDown />
</DropdownMenu>
</label>
{importMode === 'new' && (
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>New rundown name</span>
<Input
value={newRundownTitle}
onChange={(event) => setNewRundownTitle(event.target.value)}
placeholder={state.preview?.rundown.title || 'Imported rundown'}
aria-label='New rundown name'
/>
</label>
)}
</Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button> </Button>
{onExport && ( )}
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}> <Button
<IoArrowUpOutline /> variant={state.preview ? undefined : 'primary'}
Export onClick={handlePreviewSubmit}
</Button> disabled={!canPreview}
)} loading={state.loading === 'preview'}
<Button >
variant={state.preview ? undefined : 'primary'} <IoEye />
onClick={handlePreviewSubmit} Preview import
disabled={!canPreview} </Button>
loading={state.loading === 'preview'} <Button
> variant='primary'
<IoEye /> onClick={handleApply}
Preview import disabled={!state.preview || isBusy}
</Button> loading={state.loading === 'apply'}
<ApplyImportButton >
preview={state.preview} Apply import
mode={importMode} </Button>
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Section> </Panel.Section>
); );
@@ -1,21 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
builtInFieldDefs, builtInFieldDefs,
convertToImportMap, convertToImportMap,
createDefaultFormValues, createDefaultFormValues,
defaultImportMode,
getImportWarnings, getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields, getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils'; } from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue'); const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title'); const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => { describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => { it('warns when two mappings target the same spreadsheet column', () => {
@@ -134,64 +128,3 @@ describe('convertToImportMap()', () => {
}); });
}); });
}); });
describe('getProvidedImportFields()', () => {
it('reports the mapped built-in and custom fields the sheet supplies', () => {
const values = createDefaultFormValues();
values.builtIn[titleIndex] = { header: 'title', enabled: true };
values.builtIn[cueIndex] = { header: '', enabled: false };
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
const provided = getProvidedImportFields(convertToImportMap(values));
// enabled built-in mappings are reported as event fields, disabled ones are not
expect(provided.event).toContain('title');
expect(provided.event).not.toContain('cue');
// the id column is only used for matching, never overwritten
expect(provided.event).not.toContain('id');
// custom fields are reported symmetrically by their resolved Ontime name
expect(provided.custom).toStrictEqual(['FOH Monitor']);
});
});
describe('isIdColumnMapped()', () => {
it('is true when the ID field is enabled with a header', () => {
// the default form maps the ID column
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
});
it('is false when the ID field is disabled', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: 'id', enabled: false };
expect(isIdColumnMapped(values)).toBe(false);
});
it('is false when the ID field header is blank', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: ' ', enabled: true };
expect(isIdColumnMapped(values)).toBe(false);
});
});
describe('import mode persistence', () => {
const sourceKey = 'excel-test';
beforeEach(() => {
localStorage.clear();
});
it('defaults to override when nothing is persisted', () => {
expect(getPersistedImportMode(sourceKey)).toBe('override');
expect(defaultImportMode).toBe('override');
});
it('round-trips a persisted value', () => {
persistImportMode(sourceKey, 'merge');
expect(getPersistedImportMode(sourceKey)).toBe('merge');
});
it('falls back to the default when the persisted value is invalid', () => {
persistImportMode(sourceKey, 'nonsense' as never);
expect(getPersistedImportMode(sourceKey)).toBe('override');
});
});
@@ -1,4 +1,3 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage'; import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -50,16 +49,6 @@ export function createDefaultFormValues(): ImportFormValues {
}; };
} }
/**
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
*/
export function isIdColumnMapped(values: ImportFormValues): boolean {
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
const field = values.builtIn[idIndex];
return Boolean(field?.enabled && field.header.trim());
}
function sanitiseOntimeCustomFieldLabel(importName: string): string { function sanitiseOntimeCustomFieldLabel(importName: string): string {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces. // Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName const sanitised = importName
@@ -92,24 +81,6 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
}); });
} }
/**
* Returns the fields the import map supplies — the complete description of what the incoming data
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
* onto a matched event and keep everything else (e.g. automations) untouched.
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
* for matching, not overwritten.
*/
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
const event: string[] = [];
for (const [key, value] of Object.entries(importMap)) {
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
if (typeof value === 'string' && value.trim() !== '') {
event.push(key);
}
}
return { event, custom: Object.keys(importMap.custom) };
}
export function convertToImportMap(values: ImportFormValues): ImportMap { export function convertToImportMap(values: ImportFormValues): ImportMap {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>( const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => { (accumulator, { ontimeName, importName }) => {
@@ -159,12 +130,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } { export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey); const storageKey = getImportMapKey(sourceKey);
try { try {
const persistedData = localStorage.getItem(storageKey); const raw = localStorage.getItem(storageKey);
if (!persistedData) { if (!raw) {
return { values: createDefaultFormValues(), isPersisted: false }; return { values: createDefaultFormValues(), isPersisted: false };
} }
const parsed: unknown = JSON.parse(persistedData); const parsed: unknown = JSON.parse(raw);
if (isPersistedFormValues(parsed)) { if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true }; return { values: parsed, isPersisted: true };
} }
@@ -179,32 +150,6 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
} }
} }
/**
* The import mode (new / merge / override) is persisted separately from the field mapping
* so the mapping schema guard stays untouched.
*/
/** Default import mode: replace matched elements in the current rundown */
export const defaultImportMode: RundownImportMode = 'override';
function getImportModeKey(sourceKey: string) {
return makeStageKey(`import-mode:${sourceKey}`);
}
/** Persists the import mode for a given source */
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
localStorage.setItem(getImportModeKey(sourceKey), mode);
}
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
return persisted;
}
return defaultImportMode;
}
/** /**
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns. * Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
*/ */
@@ -1,35 +1,27 @@
.emptyState { .emptyState {
padding: 3rem 1.5rem; height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
text-align: center; text-align: center;
} }
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle { .emptyTitle {
margin-bottom: 0.25rem; color: $ui-white;
color: rgba($gray-200, 0.72); font-size: 1rem;
font-size: calc(1rem + 2px); font-weight: 600;
font-weight: 400;
} }
.emptyBody { .emptyBody {
color: rgba($gray-200, 0.55); color: $gray-400;
font-size: calc(1rem - 3px); font-size: 0.95rem;
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
} }
.table { .table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: collapse;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
text-align: left; text-align: left;
table-layout: auto; table-layout: auto;
@@ -42,101 +34,31 @@
} }
th { th {
color: $gray-300; font-weight: 400;
font-size: 0.8rem; color: $gray-400;
font-weight: 600; text-transform: capitalize;
letter-spacing: 0.02em; vertical-align: top;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal; white-space: normal;
} }
th, th,
td { td {
box-sizing: border-box; padding: 0.5rem;
min-width: 8rem; min-width: 8rem;
max-width: 20rem; vertical-align: top;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
} }
tbody tr { tr:nth-child(even) {
--entry-colour: transparent; background-color: $white-1;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
} }
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
} }
.rowNumber { .rowNumber {
left: 0; width: 4.5rem;
width: 3.25rem; min-width: 4.5rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
} }
.rowType { .rowType {
left: 3.25rem; width: 7rem;
width: 6.25rem; min-width: 7rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
} }
@@ -1,11 +1,7 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types'; import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils'; import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss'; import style from './PreviewTable.module.scss';
@@ -13,85 +9,14 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps { interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null; preview: SpreadsheetPreviewResponse | null;
columnLabels: string[]; columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean; isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[]; worksheetHeaders: string[];
} }
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({ export default function PreviewTable({
preview, preview,
columnLabels, columnLabels,
canRefresh,
isLoadingMetadata, isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders, worksheetHeaders,
}: PreviewTableProps) { }: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => { const customFieldKeyByLabel = useMemo(() => {
@@ -99,57 +24,33 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId])); return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]); }, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) { if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.'; let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) { if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...'; emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) { } else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent = emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.'; 'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
} }
return ( return (
<div className={style.emptyState}> <div className={style.emptyState}>
<div className={style.emptyMessage}> <div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyTitle}>{emptyTitle}</div> <div className={style.emptyBody}>{emptyContent}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview
</Button>
)}
</div>
</div> </div>
); );
} }
let eventIndex = 0;
return ( return (
<table className={style.table}> <table className={style.table}>
<thead> <thead>
<tr> <tr>
<th className={style.rowNumber}>#</th> <th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th> <th className={style.rowType}>Type</th>
{displayColumns.map((label, index) => ( {columnLabels.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th> <th key={`${label}-${index}`}>{label}</th>
))} ))}
</tr> </tr>
@@ -158,29 +59,16 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => { {preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId]; const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry); const isEvent = isOntimeEvent(entry);
const entryMetadata = previewMetadata?.[entryId]; if (isEvent) eventIndex++;
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour); const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
return ( return (
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}> <tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td> <td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}> <td className={style.rowType}>{hasType ? entry.type : ''}</td>
<Tag>{entryType}</Tag> {columnLabels.map((label, colIndex) => (
</td> <td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
{displayColumns.map((label, colIndex) => { ))}
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
</tr> </tr>
); );
})} })}
@@ -1,13 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
ImportedFields, import { millisToString } from 'ontime-utils';
RundownImportMode, import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -17,11 +11,8 @@ import {
builtInFieldDefs, builtInFieldDefs,
convertToImportMap, convertToImportMap,
getImportWarnings, getImportWarnings,
getPersistedImportMode,
getPersistedImportState, getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields, getResolvedCustomFields,
persistImportMode,
persistImportState, persistImportState,
} from './importMapUtils'; } from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils'; import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -33,7 +24,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse } | { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' } | { type: 'applySuccess' }
| { type: 'exportSuccess' } | { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean } | { type: 'clearPreview'; error?: string }
| { type: 'failure'; error: string } | { type: 'failure'; error: string }
| { type: 'reset' }; | { type: 'reset' };
@@ -41,14 +32,12 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export'; loading: '' | 'preview' | 'apply' | 'export';
error: string; error: string;
preview: SpreadsheetPreviewResponse | null; preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
}; };
const initialImportState: ImportState = { const initialImportState: ImportState = {
loading: '', loading: '',
error: '', error: '',
preview: null, preview: null,
needsPreviewRefresh: false,
}; };
function importReducer(state: ImportState, action: ImportAction): ImportState { function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -60,15 +49,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport': case 'startExport':
return { ...state, loading: 'export', error: '' }; return { ...state, loading: 'export', error: '' };
case 'previewSuccess': case 'previewSuccess':
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false }; return { loading: '', error: '', preview: action.preview };
case 'applySuccess': case 'applySuccess':
case 'exportSuccess': case 'exportSuccess':
return { ...state, loading: '' }; return { ...state, loading: '' };
case 'clearPreview': case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false }; return { ...state, error: action.error ?? '', preview: null };
case 'failure': { case 'failure': {
if (state.loading === 'preview') { if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false }; return { loading: '', error: action.error, preview: null };
} }
return { ...state, loading: '', error: action.error }; return { ...state, loading: '', error: action.error };
} }
@@ -111,23 +100,16 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps { interface UseSheetImportFormProps {
sourceKey: string; sourceKey: string;
defaultRundownName: string;
worksheetNames: string[]; worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null; initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>; loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>; previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: ( onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>; onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
} }
export function useSheetImportForm({ export function useSheetImportForm({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -188,8 +170,6 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values); const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState); const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers); const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length; const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null); const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -202,12 +182,6 @@ export function useSheetImportForm({
dispatch({ type: 'reset' }); dispatch({ type: 'reset' });
}, [initialFormValues, reset]); }, [initialFormValues, reset]);
// Update import mode and rundown name if the source changes
useEffect(() => {
setImportMode(getPersistedImportMode(sourceKey));
setNewRundownTitle(defaultRundownName);
}, [sourceKey, defaultRundownName]);
// Keep the worksheet selection valid if the available worksheets change underneath the form. // Keep the worksheet selection valid if the available worksheets change underneath the form.
useEffect(() => { useEffect(() => {
if (worksheetNames.length === 0) return; if (worksheetNames.length === 0) return;
@@ -224,7 +198,7 @@ export function useSheetImportForm({
const sub = watch(() => { const sub = watch(() => {
if (!previewRef.current) return; if (!previewRef.current) return;
previewRef.current = null; previewRef.current = null;
dispatch({ type: 'clearPreview', needsRefresh: true }); dispatch({ type: 'clearPreview' });
}); });
return () => sub.unsubscribe(); return () => sub.unsubscribe();
}, [watch]); }, [watch]);
@@ -267,15 +241,13 @@ export function useSheetImportForm({
try { try {
dispatch({ type: 'startApply' }); dispatch({ type: 'startApply' });
const providedFields = getProvidedImportFields(convertToImportMap(getValues())); await onApply(state.preview);
await onApply(state.preview, importMode, newRundownTitle, providedFields);
persistImportState(sourceKey, getValues()); persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' }); dispatch({ type: 'applySuccess' });
} catch (error) { } catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) }); dispatch({ type: 'failure', error: maybeAxiosError(error) });
} }
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]); }, [getValues, onApply, sourceKey, state.preview]);
const handleExport = useCallback( const handleExport = useCallback(
async (formValues: ImportFormValues) => { async (formValues: ImportFormValues) => {
@@ -300,37 +272,15 @@ export function useSheetImportForm({
}, [append]); }, [append]);
const toolbarStatus = (() => { const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) { if (!state.preview) {
return { return `entries: | start: | end: | duration: ${warningText}`;
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
} }
const { entries, flatOrder } = state.preview.rundown; const { flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary; const { start, end, duration } = state.preview.summary;
let groups = 0; return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
})(); })();
return { return {
@@ -350,10 +300,6 @@ export function useSheetImportForm({
isBusy, isBusy,
canPreview, canPreview,
displayError, displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview), handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport), handleExportSubmit: handleSubmit(handleExport),
handleApply, handleApply,
@@ -1,17 +1,30 @@
import { RundownImportPayload } from 'ontime-types'; import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { importRundownWithOptions } from '../../../../../common/api/rundown'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
export default function useSpreadsheetImport() { export default function useSpreadsheetImport() {
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */ const queryClient = useQueryClient();
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update /** applies rundown and customFields to current project */
// through that single path rather than racing it with an optimistic write from here const importRundown = useCallback(
await importRundownWithOptions(payload); async (rundowns: ProjectRundowns, customFields: CustomFields) => {
}, []); await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: RUNDOWN,
});
await queryClient.invalidateQueries({
queryKey: CUSTOM_FIELDS,
});
},
[queryClient],
);
return { return {
applyImport, importRundown,
}; };
} }
@@ -73,10 +73,6 @@
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.tagOvertime {
color: $playback-over;
}
.time { .time {
color: $section-white; color: $section-white;
font-size: $text-body-size; font-size: $text-body-size;
@@ -1,13 +1,12 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types'; import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { dayInMs, millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink'; import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { useTimerProgress } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils'; import { formatDuration } from '../../../../common/utils/time';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay'; import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss'; import style from './PlaybackTimer.module.scss';
@@ -25,14 +24,15 @@ function resolveAddedTimeLabel(addedTime: number) {
} }
export default function PlaybackTimer({ children }: PropsWithChildren) { export default function PlaybackTimer({ children }: PropsWithChildren) {
'use memo'; const timer = useTimer();
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll; const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending; const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime; const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime); const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : ''; const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime); const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return ( return (
@@ -51,13 +51,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? ( {isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span> <span className={style.rolltag}>Roll: Countdown to start</span>
) : ( ) : (
<RunningStatus <RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
)} )}
</div> </div>
{children} {children}
@@ -68,18 +62,16 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps { interface RunningStatusProps {
startedAt: MaybeNumber; startedAt: MaybeNumber;
expectedFinish: MaybeNumber; expectedFinish: MaybeNumber;
isStopped: boolean; playback: Playback;
isCountToEnd: boolean;
isOvertime: boolean;
} }
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) { if (playback === Playback.Stop) {
if (isStopped) {
return <StoppedStatus />; return <StoppedStatus />;
} }
const started = millisToString(startedAt); const started = millisToString(startedAt);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish)); const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return ( return (
<> <>
@@ -88,9 +80,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
<span className={style.time}>{started}</span> <span className={style.time}>{started}</span>
</span> </span>
<span className={style.finish}> <span className={style.finish}>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}> <span className={style.tag}>Expect end</span>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.time}>{finish}</span> <span className={style.time}>{finish}</span>
</span> </span>
</> </>
+102 -110
View File
@@ -1,7 +1,6 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Operator {...data} />; return <Operator {...data} />;
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -116,7 +113,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]); const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
{!hasEvents ? ( <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
<EmptyFill text={getLocalizedString('common.no_data')} /> {rundown.order.map((entryId) => {
) : ( const entry = rundown.entries[entryId];
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> if (isOntimeEvent(entry)) {
{rundown.order.map((entryId) => { const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
const entry = rundown.entries[entryId]; // hide past events (if setting) and skipped events
if (isOntimeEvent(entry)) { if ((hidePast && isPast) || entry.skip) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; return null;
// hide past events (if setting) and skipped events }
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( const { mainField, secondaryField, subscribedData } = getEventData(
entry, entry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
customFields, customFields,
); );
return ( return (
<OperatorEvent <OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id} key={entry.id}
id={entry.id} title={entry.title}
colour={entry.colour} colour={entry.colour}
cue={entry.cue} count={entry.entries.length}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration} duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/> />
); {entry.entries.map((nestedEntryId) => {
} const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
if (isOntimeGroup(entry)) { const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false; // hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
if (hidePast && isPast && !isCurrentParent) { const { mainField, secondaryField, subscribedData } = getEventData(
return null; nestedEntry,
} mainSource,
secondarySource,
subscribe,
customFields,
);
return ( return (
<Fragment key={entry.id}> <OperatorEvent
<OperatorGroup key={nestedEntry.id}
key={entry.id} id={nestedEntry.id}
title={entry.title} colour={nestedEntry.colour}
colour={entry.colour} cue={nestedEntry.cue}
count={entry.entries.length} main={mainField}
duration={entry.duration} secondary={secondaryField}
/> timeStart={nestedEntry.timeStart}
{entry.entries.map((nestedEntryId) => { duration={nestedEntry.duration}
const nestedEntry = rundown.entries[nestedEntryId]; delay={nestedEntry.delay}
if (!isOntimeEvent(nestedEntry)) { dayOffset={nestedEntry.dayOffset}
return null; isLinkedToLoaded={isLinkedToLoaded}
} isSelected={isLoaded}
isPast={isPast}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId]; groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
// hide past events (if setting) and skipped events showStart={showStart}
if ((hidePast && isPast) || nestedEntry.skip) { subscribed={subscribedData}
return null; totalGap={totalGap}
} onLongPress={canEdit ? handleEdit : () => undefined}
/>
const { mainField, secondaryField, subscribedData } = getEventData( );
nestedEntry, })}
mainSource, </Fragment>
secondarySource, );
subscribe, }
customFields, return null;
); })}
</div>
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div> </div>
); );
@@ -14,18 +14,9 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
data: rundown, const { data: customFields, status: customFieldStatus } = useCustomFields();
rundownMetadata, const { data: settings, status: settingsStatus } = useSettings();
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
return { return {
data: { data: {
@@ -34,10 +25,6 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields, customFields,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
]),
}; };
} }
+1 -20
View File
@@ -105,29 +105,12 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
handleCollapseGroup, handleCollapseGroup,
}); });
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
const jumpToCurrent = useCallback(() => {
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
if (!targetId) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[targetId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(targetId);
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
// Keyboard shortcuts // Keyboard shortcuts
useRundownKeyboard({ useRundownKeyboard({
cursor, cursor,
commands, commands,
clearSelectedEvents, clearSelectedEvents,
setEntryCopyId, setEntryCopyId,
jumpToCurrent,
}); });
// DND handlers // DND handlers
@@ -339,9 +322,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }} increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }} style={{ height: '100%' }}
components={{ components={{
Header: isEditMode Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Footer: () => ( Footer: () => (
<> <>
{isEditMode && ( {isEditMode && (
@@ -1,24 +1,19 @@
import { memo } from 'react'; import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty const isLoading = status !== 'success' || !data || !rundownMetadata;
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoadingError) { if (isLoading) {
return <EmptyFill text={getLocalizedString('common.no_data')} />; return <Empty text='Connecting to server' />;
} }
return ( return (
@@ -44,9 +44,6 @@ function EventEditorEmpty() {
<Separator /> <Separator />
<Combo keys={['PgDn']} /> <Combo keys={['PgDn']} />
</Shortcut> </Shortcut>
<Shortcut label='Jump to current entry'>
<Combo keys={[deviceAlt, 'L']} />
</Shortcut>
<Shortcut label='Deselect entry'> <Shortcut label='Deselect entry'>
<Combo keys={['Esc']} /> <Combo keys={['Esc']} />
</Shortcut> </Shortcut>
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss'; import style from '../EntryEditor.module.scss';
@@ -68,10 +68,8 @@ function EventEditorTimes({
}; };
const hasDelay = delay !== 0; const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}` ? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)}${formatTime(timeEnd + delay)}`
: ''; : '';
return ( return (
@@ -88,6 +86,7 @@ function EventEditorTimes({
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
delay={delay} delay={delay}
countToEnd={countToEnd}
showLabels showLabels
/> />
</div> </div>
@@ -11,17 +11,13 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps { interface QuickAddButtonsProps {
previousEventId: MaybeString; previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString; parentGroup: MaybeString;
backgroundColor?: string; backgroundColor?: string;
} }
export default memo(QuickAddButtons); export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) { function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext(); const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => { const addEvent = () => {
addEntry( addEntry(
@@ -29,23 +25,44 @@ function QuickAddButtons({ previousEventId, nextEventId, parentGroup, background
type: SupportedEntry.Event, type: SupportedEntry.Event,
parent: parentGroup, parent: parentGroup,
}, },
insertionOptions, {
after: previousEventId,
lastEventId: previousEventId,
},
); );
}; };
const addDelay = () => { const addDelay = () => {
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions); addEntry(
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
const addMilestone = () => { const addMilestone = () => {
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions); addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
const addGroup = () => { const addGroup = () => {
if (parentGroup !== null) { if (parentGroup !== null) {
return; return;
} }
addEntry({ type: SupportedEntry.Group }, insertionOptions); addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
/** /**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, { addEntry(patch, {
after: id && !above ? id : undefined, after: id && !above ? id : undefined,
before: id && above ? id : undefined, before: id && above ? id : undefined,
lastEventId: id && !above ? id : undefined, lastEventId: !above && id ? id : undefined,
}); });
}, },
[addEntry], [addEntry],
@@ -19,7 +19,6 @@ interface UseRundownKeyboardOptions {
}; };
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void; setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
} }
/** /**
@@ -41,7 +40,6 @@ export function useRundownKeyboard({
commands, commands,
clearSelectedEvents, clearSelectedEvents,
setEntryCopyId, setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) { }: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -129,8 +127,6 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true }, { preventDefault: true, usePhysicalKeys: true },
], ],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[ [
'alt + mod + ArrowDown', 'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'), () => commands.moveEntry(cursor, 'down'),
@@ -7,7 +7,6 @@ $skip-opacity: 0.2;
background-color: $block-bg; background-color: $block-bg;
margin-block: 0.25rem; margin-block: 0.25rem;
overflow: initial; overflow: initial;
position: relative;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
@@ -17,7 +16,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus' 'binder pb-actions estatus estatus'
'binder ... ... ...'; 'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem; grid-template-columns: $block-binder-width 3rem 1fr 3rem;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem; grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center; align-items: center;
padding-right: $block-clearance; padding-right: $block-clearance;
@@ -137,7 +136,6 @@ $skip-opacity: 0.2;
align-items: center; align-items: center;
gap: $block-clearance; gap: $block-clearance;
height: 100%; height: 100%;
min-width: 0;
} }
.eventTimers.editMode:hover { .eventTimers.editMode:hover {
@@ -157,7 +155,7 @@ $skip-opacity: 0.2;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.warningMeta { .nextTag {
font-size: 1rem; font-size: 1rem;
color: $orange-500; color: $orange-500;
letter-spacing: 0.03px; letter-spacing: 0.03px;
@@ -233,10 +231,6 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator); color: var(--status-color-active-override, $active-indicator);
} }
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning { .statusIcon.warning {
color: $orange-500; color: $orange-500;
} }
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay} delay={delay}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
countToEnd={countToEnd}
/> />
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} /> <TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.warningMeta}>UP NEXT</span>} {isNext && <span className={style.nextTag}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
</div> </div>
<EventBlockPlayback <EventBlockPlayback
eventId={eventId} eventId={eventId}
@@ -157,13 +157,7 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} /> <EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip> </Tooltip>
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}> <Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine <LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
className={cx([
style.statusIcon,
countToEnd ? style.active : style.disabled,
countToEnd && style.countToEndStatus,
])}
/>
</Tooltip> </Tooltip>
<Tooltip text={automationTooltip} render={<span />}> <Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} /> <IoFlash className={automationIconClasses} />
@@ -1,33 +1,10 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils'; import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => { describe('formatDelay()', () => {
it('adds a given delay to the start time', () => { it('adds a given delay to the start time', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01 const timeStart = 60000; // 1 min
const delay = 1 * MILLIS_PER_MINUTE; // 00:01 const delay = 60000; // 1 min
const result = formatDelay(timeStart, delay); const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02'); expect(result).toEqual('New start 00:02');
}); });
it('wraps negative delayed starts under midnight', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:59');
});
it('wraps later-day negative delays using delay as the source of truth', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:30');
});
it('displays positive delays as wall-clock time', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 02:30');
});
}); });
@@ -1,16 +1,15 @@
import { millisToString, removeTrailingZero } from 'ontime-utils'; import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration, normaliseWallClock } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined { export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return; if (!delay) return;
const delayedStart = normaliseWallClock(timeStart + delay); const delayedStart = Math.max(0, timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart)); const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`; return `New start ${timeTag}`;
} }
export function formatGap(gap: number, isNextDay: boolean) { export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) { if (gap === 0) {
if (isNextDay) { if (isNextDay) {
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown'; import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -12,7 +13,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource(); const source = useLoadedRundownSource();
@@ -31,10 +32,16 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<EntryActionsProvider actions={actions}> <EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'> <CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' /> {isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd> </CuesheetDnd>
</EntryActionsProvider> </EntryActionsProvider>
); );
@@ -4,15 +4,10 @@
.timerNote { .timerNote {
width: 1.25em; width: 1.25em;
flex: 0 0 1.25em;
color: $blue-500; color: $blue-500;
font-size: 1.5em; font-size: 1.5em;
} }
.timerNotePlaceholder {
visibility: hidden;
}
.inactive { .inactive {
color: $muted-gray; color: $muted-gray;
} }
@@ -14,6 +14,7 @@ import style from './TimeInputFlow.module.scss';
interface TimeInputFlowProps { interface TimeInputFlowProps {
eventId: string; eventId: string;
countToEnd: boolean;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
@@ -21,12 +22,12 @@ interface TimeInputFlowProps {
linkStart: boolean; linkStart: boolean;
delay: number; delay: number;
showLabels?: boolean; showLabels?: boolean;
showWarnings?: boolean;
} }
export default memo(TimeInputFlow); export default memo(TimeInputFlow);
function TimeInputFlow({ function TimeInputFlow({
eventId, eventId,
countToEnd,
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
@@ -34,7 +35,6 @@ function TimeInputFlow({
linkStart, linkStart,
delay, delay,
showLabels, showLabels,
showWarnings = true,
}: TimeInputFlowProps) { }: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActionsContext(); const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -56,6 +56,10 @@ function TimeInputFlow({
warnings.push('Over midnight'); warnings.push('Over midnight');
} }
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0; const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd; const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration; const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -132,19 +136,11 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
{showWarnings && {warnings.length > 0 && (
(warnings.length > 0 ? ( <Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
<Tooltip <IoAlertCircleOutline />
text={warnings.join(' - ')} </Tooltip>
className={style.timerNote} )}
data-testid='event-warning'
render={<span />}
>
<IoAlertCircleOutline />
</Tooltip>
) : (
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
))}
</> </>
); );
} }
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Backstage {...data} />; return <Backstage {...data} />;
@@ -20,14 +20,10 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown(); const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { const { data: customFields, status: customFieldsStatus } = useCustomFields();
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -37,11 +33,6 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored, isMirrored,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid; display: grid;
place-items: center; place-items: center;
background-color: var(--background-color-override, $viewer-background-color); background-color: var(--background-color-override, $viewer-background-color);
height: 100dvh; height: 100vh;
} }
.ellipsis { .ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size; height: $dot-size;
border-radius: 50%; border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color); background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); animation-timing-function: cubic-bezier(0, 1, 1, 0);
&:nth-child(1) { &:nth-child(1) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis1 1s infinite; animation: lds-ellipsis1 0.6s infinite;
} }
&:nth-child(2) { &:nth-child(2) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis2 1s infinite; animation: lds-ellipsis2 0.6s infinite;
} }
&:nth-child(3) { &:nth-child(3) {
left: calc($dot-size + $dot-spacing); left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 1s infinite; animation: lds-ellipsis2 0.6s infinite;
} }
&:nth-child(4) { &:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing); left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 1s infinite; animation: lds-ellipsis3 0.6s infinite;
} }
} }
} }
+15 -1
View File
@@ -80,7 +80,21 @@ $item-height: 3.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1.5rem;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
} }
.list-container { .list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Countdown {...data} />; return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && ( {!hasEvents && (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} /> <Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div> </div>
)} )}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} /> <Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
</div> </div>
); );
} }
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
//TODO: consider relative mode //TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) { export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props; const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, expectedEnd } = event; const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs; const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
@@ -164,6 +164,7 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`; const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay; const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`; const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return ( return (
@@ -1,15 +1,17 @@
import { getExpectedEnd, getExpectedStart } from 'ontime-utils'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import useReport from '../../common/hooks-query/useReport'; import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket'; import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils'; import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions'; import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss'; import './SingleEventCountdown.scss';
@@ -36,10 +38,8 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode, mode,
}); });
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null }; const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd }; const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title'); const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing; const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
// while a group is live, surface the running event's title as the secondary line // while a group is live, surface the running event's title as the secondary line
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
} }
interface SubscriptionStatusProps { interface SubscriptionStatusProps {
event: CountdownEvent; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
} }
function SubscriptionStatus({ event }: SubscriptionStatusProps) { function SubscriptionStatus({ event }: SubscriptionStatusProps) {
@@ -12,7 +12,7 @@ import {
isOntimeGroup, isOntimeGroup,
isPlayableEvent, isPlayableEvent,
} from 'ontime-types'; } from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils'; import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket'; import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
}; };
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number }; export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
/** /**
* Resolves a subscription (event or group) into an event-shaped countdown target. * Resolves a subscription (event or group) into an event-shaped countdown target.
@@ -271,7 +271,6 @@ export function extendEventData(
offset, offset,
mode, mode,
}); });
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null }; const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt, expectedEnd }; return { ...event, expectedStart, endedAt };
} }
@@ -21,18 +21,10 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
data: rundownData, const { data: projectData, status: projectDataStatus } = useProjectData();
status: rundownStatus, const { data: settings, status: settingsStatus } = useSettings();
isLoadingError: rundownIsLoadingError, const { data: customFields, status: customFieldsStatus } = useCustomFields();
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -42,11 +34,6 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored, isMirrored,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -2,6 +2,7 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react'; import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select'; import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown'; import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -33,34 +34,40 @@ function CuesheetTableWrapper({
const preset = use(PresetContext); const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo( const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset), () => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset], [customFields, cuesheetMode, preset],
); );
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
<CuesheetTable {isLoading ? (
columns={columns} <EmptyPage text='Loading...' />
source={source} ) : (
cuesheetMode={cuesheetMode} <CuesheetTable
tableRoot='cuesheet' columns={columns}
setCuesheetMode={setCuesheetMode} source={source}
isCurrentRundown={isCurrentRundown} cuesheetMode={cuesheetMode}
insertElement={ tableRoot='cuesheet'
<> setCuesheetMode={setCuesheetMode}
<RundownSelect isCurrentRundown={isCurrentRundown}
cuesheetMode={cuesheetMode} insertElement={
selectedRundownId={selectedRundownId} <>
loadedRundownId={loadedRundownId} <RundownSelect
setSelectedRundownId={setSelectedRundownId} cuesheetMode={cuesheetMode}
projectRundowns={projectRundowns} selectedRundownId={selectedRundownId}
/> loadedRundownId={loadedRundownId}
</> setSelectedRundownId={setSelectedRundownId}
} projectRundowns={projectRundowns}
/> />
</>
}
/>
)}
</CuesheetDnd> </CuesheetDnd>
); );
} }
@@ -5,10 +5,6 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size); @include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet { .cuesheet {
font-size: $table-font-size; font-size: $table-font-size;
font-weight: 400; font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle, TableVirtuosoHandle,
} from 'react-virtuoso'; } from 'react-virtuoso';
import EmptyFill from '../../../common/components/state/EmptyFill'; import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,7 +19,6 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions'; import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -63,9 +62,8 @@ export default function CuesheetTable({
isCurrentRundown, isCurrentRundown,
insertElement, insertElement,
}: CuesheetTableProps) { }: CuesheetTableProps) {
const { flatRundown, status, isLoadingError, selectedEventId } = source; const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -230,13 +228,10 @@ export default function CuesheetTable({
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [cuesheetMode, hideIndexColumn, table]);
// avoid showing the editable empty state before we know whether the rundown is actually empty const isLoading = !flatRundown || status === 'pending';
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoadingError) { if (isLoading) {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />; return <EmptyPage text='Loading...' />;
} }
return ( return (
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
{ {
type: 'item', type: 'item',
label: 'Planning', label: 'Planning',
description: 'Edit focused list with planning stats', description: 'Edit-focused list with planning stats',
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined, icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING), onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
}, },
@@ -28,9 +28,9 @@
.timer-container { .timer-container {
flex: 1; flex: 1;
display: flex; align-content: center;
flex-direction: column; justify-self: center;
justify-content: center; align-self: center;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -1,7 +1,5 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project { .project {
margin: 0; margin: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
@@ -18,104 +16,56 @@ $content-width: min(100%, 1100px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo { .logo {
max-width: min(200px, 30vw); max-width: min(200px, 30vw);
} }
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info { .info {
flex: 1; flex: 1;
width: $content-width; max-height: 100%;
margin-inline: auto; margin-inline: auto;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start;
gap: $view-element-gap; gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh; padding-bottom: 10vh;
} }
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label { .info__label {
font-size: $timer-label-size;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.info__value { .info__value {
white-space: break-spaces; white-space: break-spaces;
line-height: 1.35; }
overflow-wrap: anywhere;
.info__custom {
display: flex;
gap: 1rem;
} }
.info__image-container { .info__image-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex: 0 0 min(192px, 25%); width: 192px;
height: 192px;
} }
.info__image { .info__image {
display: block;
max-width: 100%; max-width: 100%;
height: auto; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
.link.info__value { .link.info__value {
display: inline-flex; display: flex;
gap: 0.35em; gap: $view-element-gap;
align-items: center; align-items: center;
color: $action-text-color; color: $action-text-color;
@@ -128,13 +78,12 @@ $content-width: min(100%, 1100px);
/* =================== MOBILE ===================*/ /* =================== MOBILE ===================*/
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.project { .project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img { .logo img {
height: min(50px, 10vh); height: min(50px, 10vh);
} }
.info__image-container {
width: 96px;
height: 96px;
}
} }
} }
@@ -1,5 +1,4 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -22,7 +21,7 @@ export default function ProjectInfoLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <ProjectInfo {...data} />; return <ProjectInfo {...data} />;
@@ -42,49 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return ( return (
<> <>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} /> <EmptyPage text={getLocalizedString('common.no_data')} />;
</> </>
); );
} }
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return ( return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'> <div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{hasHeader && ( {projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
<div className='info'> <div className='info'>
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>} {projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.url && ( {projectData.url && (
<div className='info__card'> <div>
<div className='info__label'>{getLocalizedString('project.url')}</div> <div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'> <a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url} {projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
<IoOpenOutline style={{ fontSize: '1em' }} />
</a> </a>
</div> </div>
)} )}
{projectData.custom.map((info, idx) => { {projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return ( return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here // oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__card'> <div key={`${info.title}-${idx}`} className='info__custom'>
{info.title && <div className='info__label'>{info.title}</div>} {hasUrl && (
{info.url ? ( <div className='info__image-container'>
<div className='info__media'> <img className='info__image' src={info.url} loading='lazy' />
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div> </div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)} )}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div> </div>
); );
})} })}
@@ -92,31 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div> </div>
); );
} }
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
@@ -2,7 +2,7 @@ import { ProjectData } from 'ontime-types';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; import { ViewData } from '../utils/viewLoader.utils';
export interface ProjectInfoData { export interface ProjectInfoData {
projectData: ProjectData; projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
return { return {
data: { data: {
projectData, projectData,
isMirrored, isMirrored,
}, },
status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]), status: projectDataStatus,
}; };
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Studio {...data} />; return <Studio {...data} />;
+5 -18
View File
@@ -20,18 +20,10 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
data: viewSettings, const { data: settings, status: settingsStatus } = useSettings();
status: viewSettingsStatus, const { data: customFields, status: customFieldsStatus } = useCustomFields();
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -41,11 +33,6 @@ export function useStudioData(): ViewData<StudioData> {
settings, settings,
viewSettings, viewSettings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -1,7 +1,6 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -30,7 +29,7 @@ export default function TimelinePageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <TimelinePage {...data} />; return <TimelinePage {...data} />;
@@ -74,7 +73,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration} totalDuration={totalDuration}
/> />
) : ( ) : (
<EmptyFill text={getLocalizedString('common.no_data')} /> <EmptyPage text={getLocalizedString('common.no_data')} />
)} )}
</div> </div>
); );
@@ -16,18 +16,10 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
data: rundownData, const { data: projectData, status: projectDataStatus } = useProjectData();
status: rundownStatus, const { data: settings, status: settingsStatus } = useSettings();
isLoadingError: rundownIsLoadingError, const { data: customFields, status: customFieldsStatus } = useCustomFields();
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -36,11 +28,6 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData, projectData,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
+3 -3
View File
@@ -82,9 +82,9 @@
.timer-container { .timer-container {
flex: 1; flex: 1;
display: flex; align-content: center;
flex-direction: column; justify-self: center;
justify-content: center; align-self: center;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Timer {...data} />; return <Timer {...data} />;
+10 -18
View File
@@ -22,19 +22,11 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
data: viewSettings, const { data: settings, status: settingsStatus } = useSettings();
status: viewSettingsStatus, const { data: customFields, status: customFieldsStatus } = useCustomFields();
isLoadingError: viewSettingsIsLoadingError, const { data: rundown, status: rundownStatus } = useRundown();
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundown, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useRundown();
const { entries } = rundown; const { entries } = rundown;
return { return {
@@ -47,11 +39,11 @@ export function useTimerData(): ViewData<TimerData> {
entries, entries,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }, projectDataStatus,
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError }, viewSettingsStatus,
{ status: settingsStatus, isLoadingError: settingsIsLoadingError }, settingsStatus,
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError }, customFieldsStatus,
{ status: rundownStatus, isLoadingError: rundownIsLoadingError }, rundownStatus,
]), ]),
}; };
} }
+11 -17
View File
@@ -5,27 +5,21 @@ export type ViewData<T> = {
status: QueryStatus; status: QueryStatus;
}; };
type AggregatableQuery = {
status: QueryStatus;
/** true only when the query has never received data, ie useQuery's isLoadingError */
isLoadingError: boolean;
};
/** /**
* Aggregates a loading status from multiple queries, for the purpose of deciding * Aggregates a loading status from multiple query statuses.
* whether a view can render. * If all statuses are 'pending', returns 'pending'.
* - 'pending' while any query hasn't settled yet (no result, first fetch in flight) * If all statuses are 'success', returns 'success'.
* - 'error' once all queries have settled, if any of them never received data * If any status is 'error', returns 'error'.
* - 'success' once all queries have settled and every one has data to show,
* even if a query's last (background) fetch failed
*/ */
export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus { export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus {
const allSettled = queries.every((query) => query.status !== 'pending'); if (statuses.every((status) => status === 'pending')) {
if (!allSettled) {
return 'pending'; return 'pending';
} }
if (queries.some((query) => query.isLoadingError)) { if (statuses.every((status) => status === 'success')) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
return 'error'; return 'error';
} }
return 'success'; return 'pending';
} }
+1 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.11.0", "version": "4.10.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -125,13 +125,6 @@
"**/*", "**/*",
"*{.ts}" "*{.ts}"
] ]
},
{
"from": "../server/src/html/",
"to": "extraResources/html/",
"filter": [
"**/*"
]
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.11.0", "version": "4.10.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.11.0", "version": "4.10.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -163,70 +163,5 @@ describe('parseOutput', () => {
secondarySource: 'secondary', secondarySource: 'secondary',
}); });
}); });
it('parses message-secondary with a text value', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: undefined,
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'hello',
}),
).toMatchObject({
secondarySource: 'secondary',
text: 'hello',
});
// an empty text is treated as no change
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: '',
}),
).toMatchObject({
secondarySource: 'secondary',
text: undefined,
});
// text can be set while clearing the secondary source
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text: 'hello',
}),
).toMatchObject({
secondarySource: null,
text: 'hello',
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 123,
}),
).toThrow('Unexpected payload type:');
});
}); });
}); });
@@ -1,113 +0,0 @@
import { runtimeStorePlaceholder } from 'ontime-types';
import * as messageService from '../../../services/message-service/message.service.js';
import { toOntimeAction } from '../clients/ontime.client.js';
vi.mock('../../../services/message-service/message.service.js', () => ({
patch: vi.fn(),
}));
describe('toOntimeAction()', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('parses templates in primary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-set',
text: 'Current: {{timer.current}}',
visible: true,
},
{
...runtimeStorePlaceholder,
timer: {
...runtimeStorePlaceholder.timer,
current: 42,
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
text: 'Current: 42',
visible: true,
},
});
});
it('parses templates in secondary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
secondarySource: 'secondary',
},
secondary: 'Next: Keynote',
});
});
it('can set secondary message text without changing the secondary source', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
secondary: 'Next: Keynote',
});
});
});
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
} else if (isHTTPOutput(payload)) { } else if (isHTTPOutput(payload)) {
emitHTTP(payload, store); emitHTTP(payload, store);
} else if (isOntimeAction(payload)) { } else if (isOntimeAction(payload)) {
toOntimeAction(payload, store); toOntimeAction(payload);
} else { } else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`); logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
} }
@@ -225,28 +225,13 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
} }
if (maybeOntimeAction.action === 'message-secondary') { if (maybeOntimeAction.action === 'message-secondary') {
// the secondary text is optional, an empty string is treated as no change assert.hasKeys(maybeOntimeAction, ['secondarySource']);
let text: string | undefined = undefined;
if ('text' in maybeOntimeAction) {
assert.isString(maybeOntimeAction.text);
text = indeterminateText(maybeOntimeAction.text);
}
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
return {
type: 'ontime',
action: 'message-secondary',
text,
};
}
// null is used to clear the secondary source // null is used to clear the secondary source
if (maybeOntimeAction.secondarySource === null) { if (maybeOntimeAction.secondarySource === null) {
return { return {
type: 'ontime', type: 'ontime',
action: 'message-secondary', action: 'message-secondary',
secondarySource: null, secondarySource: null,
text,
}; };
} }
@@ -255,7 +240,6 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
type: 'ontime', type: 'ontime',
action: 'message-secondary', action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource), secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
text,
}; };
} }
@@ -1,14 +1,12 @@
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types'; import { LogOrigin, OntimeAction } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import { DeepReadonly } from 'ts-essentials';
import { logger } from '../../../classes/Logger.js'; import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js'; import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/message.service.js'; import * as messageService from '../../../services/message-service/message.service.js';
import { runtimeService } from '../../../services/runtime-service/runtime.service.js'; import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
import { parseTemplateNested } from '../automation.utils.js';
export function toOntimeAction(action: OntimeAction, store: DeepReadonly<RuntimeStore>) { export function toOntimeAction(action: OntimeAction) {
const actionType = action.action; const actionType = action.action;
switch (actionType) { switch (actionType) {
// Aux timer actions // Aux timer actions
@@ -57,25 +55,18 @@ export function toOntimeAction(action: OntimeAction, store: DeepReadonly<Runtime
case 'message-set': { case 'message-set': {
messageService.patch({ messageService.patch({
timer: { timer: {
text: action.text ? parseTemplateNested(action.text, store) : action.text, text: action.text,
visible: action.visible, visible: action.visible,
}, },
}); });
break; break;
} }
case 'message-secondary': { case 'message-secondary': {
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text; messageService.patch({
const patch = timer: {
action.secondarySource === undefined secondarySource: action.secondarySource,
? { secondary } },
: { });
timer: {
secondarySource: action.secondarySource,
},
secondary,
};
messageService.patch(patch);
break; break;
} }
@@ -432,40 +432,6 @@ describe('processRundown()', () => {
expect(initResult.totalDuration).toBe(500 - 100); expect(initResult.totalDuration).toBe(500 - 100);
}); });
it('skipped events do not advance day offsets or affect gaps', () => {
const rundown = makeRundown({
order: ['1', 'skipped', '2'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
}),
skipped: makeOntimeEvent({
id: 'skipped',
skip: true,
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30:00
timeEnd: 8 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 08:30:00
duration: 8 * MILLIS_PER_HOUR, // 08:00:00
}),
},
});
const initResult = processRundown(rundown, {});
expect((initResult.entries.skipped as OntimeEvent).dayOffset).toBe(0);
expect((initResult.entries.skipped as OntimeEvent).gap).toBe(0);
expect((initResult.entries['2'] as OntimeEvent).dayOffset).toBe(1);
expect((initResult.entries['2'] as OntimeEvent).gap).toBe(0);
});
it('calculates total duration across days with gap', () => { it('calculates total duration across days with gap', () => {
const rundown = makeRundown({ const rundown = makeRundown({
order: ['1', '2', '3'], order: ['1', '2', '3'],
@@ -714,17 +680,17 @@ describe('processRundown()', () => {
}); });
describe('rundownMutation.add()', () => { describe('rundownMutation.add()', () => {
test('adds an event to an empty rundown', () => { test('adds an event an empty rundown', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({}); const rundown = makeRundown({});
rundownMutation.add(rundown, mockEvent, null, null, null); rundownMutation.add(rundown, mockEvent, null, null);
expect(rundown.order.length).toBe(1); expect(rundown.order.length).toBe(1);
expect(rundown.entries['mock']).toMatchObject(mockEvent); expect(rundown.entries['mock']).toMatchObject(mockEvent);
}); });
test('adds an event at the end if no afterId is given', () => { test('adds an event at the top if no afterId is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({ const rundown = makeRundown({
flatOrder: ['1'], flatOrder: ['1'],
@@ -734,28 +700,28 @@ describe('rundownMutation.add()', () => {
}, },
}); });
rundownMutation.add(rundown, mockEvent, null, null, null); rundownMutation.add(rundown, mockEvent, null, null);
expect(rundown.order).toStrictEqual(['1', 'mock']); expect(rundown.order).toStrictEqual(['mock', '1']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock']); expect(rundown.flatOrder).toStrictEqual(['mock', '1']);
expect(rundown.entries['mock']).toMatchObject(mockEvent); expect(rundown.entries['mock']).toMatchObject(mockEvent);
}); });
test('adds an event at the end of the group if parent is provided but no after', () => { test('adds an event at the top of the group if no after is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({ const rundown = makeRundown({
flatOrder: ['1', '1a'], flatOrder: ['1', '1a'],
order: ['1'], order: ['1'],
entries: { entries: {
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }), '1': makeOntimeGroup({ id: '1' }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }), '1a': makeOntimeEvent({ id: '1a', parent: '1' }),
}, },
}); });
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, null, null); rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup);
expect(rundown.order).toStrictEqual(['1']); expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
expect(rundown.entries['mock']).toMatchObject(mockEvent); expect(rundown.entries['mock']).toMatchObject(mockEvent);
}); });
@@ -765,12 +731,12 @@ describe('rundownMutation.add()', () => {
flatOrder: ['1', '1a'], flatOrder: ['1', '1a'],
order: ['1'], order: ['1'],
entries: { entries: {
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }), '1': makeOntimeGroup({ id: '1' }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }), '1a': makeOntimeEvent({ id: '1a', parent: '1' }),
}, },
}); });
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, '1a', null); rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup);
expect(rundown.order).toStrictEqual(['1']); expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
@@ -1839,49 +1805,6 @@ describe('rundownMutation.clone()', () => {
parent: null, parent: null,
}); });
}); });
it('clones an event and appends it with after true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
});
it('clones an event and prepends it with before true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['2'], { before: true });
expect(testRundown.order).toStrictEqual([newEntry.id, '1', '2']);
});
it('clones a group and appends it with after true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeGroup({ id: '1', title: 'top', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
});
}); });
describe('rundownMutation.group()', () => { describe('rundownMutation.group()', () => {
@@ -1,18 +1,8 @@
import { import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
EndAction,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
TimeStrategy,
TimerLifeCycle,
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils'; import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest'; import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
deleteById, deleteById,
@@ -20,8 +10,6 @@ import {
getIntegerAndFraction, getIntegerAndFraction,
hasChanges, hasChanges,
makeDeepClone, makeDeepClone,
mergeRundownPreservingFields,
isLoadedPlayable,
} from '../rundown.utils.js'; } from '../rundown.utils.js';
describe('test event validator', () => { describe('test event validator', () => {
@@ -299,314 +287,3 @@ describe('getIntegerAndFraction()', () => {
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 }); expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
}); });
}); });
/**
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
* any field it does not provide (e.g. automations) is kept from the existing event.
*/
describe('mergeRundownPreservingFields()', () => {
const automation: Trigger = {
id: 'trigger-onair',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-onair',
};
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
const current = makeRundown({
id: 'show-rundown',
title: 'Main show',
revision: 3,
order: ['welcome', 'keynote'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
title: 'From spreadsheet',
revision: 0,
order: ['welcome', 'lunch'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
},
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// identity and revision come from the current rundown
expect(merged.id).toBe('show-rundown');
expect(merged.title).toBe('Main show');
expect(merged.revision).toBe(4);
// structure and order come from the incoming rundown
expect(merged.order).toEqual(['welcome', 'lunch']);
expect(merged.flatOrder).toEqual(incoming.flatOrder);
});
it('deletes current entries that are absent from the incoming rundown', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['welcome', 'keynote'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome' }),
keynote: makeOntimeEvent({ id: 'keynote' }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['welcome'],
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
expect(merged.entries.welcome).toBeDefined();
expect(merged.entries.keynote).toBeUndefined();
});
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// the incoming group fully replaces the previous event, no old data is carried over
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
});
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
});
// the sheet mapped title and note
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
// an empty provided value replaces the current one
expect(keynote.note).toBe('');
});
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
},
});
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({
id: 'keynote',
title: 'Opening keynote',
note: 'from a stale column',
triggers: [],
}),
},
});
// only title is mapped, so note and automations keep the existing values, not the incoming ones
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
expect(keynote.note).toBe('green room');
expect(keynote.triggers).toEqual([automation]);
});
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({
id: 'keynote',
title: 'Keynote',
custom: { lighting: 'warm', song: 'intro theme' },
}),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
},
});
// the sheet mapped the title and the lighting custom field, but not song
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
expect(keynote.custom.lighting).toBe('cold');
// an unmapped custom field is kept from the existing event
expect(keynote.custom.song).toBe('intro theme');
});
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
});
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
});
it('keeps the existing time strategy when the provided times are ambiguous', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
});
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
});
it('merges a matched group, keeping the fields the sheet cannot express', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['session'],
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
});
// the sheet cannot express a group's target duration, so the incoming group does not carry one
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['session'],
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const session = merged.entries.session as OntimeGroup;
expect(session.title).toBe('New session');
expect(session.targetDuration).toBe(3_600_000);
});
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['reminder'],
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['reminder'],
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
});
// only title is mapped, so the milestone's note keeps the existing value
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const reminder = merged.entries.reminder as OntimeMilestone;
expect(reminder.title).toBe('Green room reminder');
expect(reminder.note).toBe('call talent');
});
it('does not mutate the current rundown and deep-clones the kept automations', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// kept automations are a copy, not a shared reference to the current rundown
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
});
it('keeps the not-provided fields through a parseRundown round-trip', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const parsed = parseRundown(merged, {});
const keynote = parsed.entries.keynote as OntimeEvent;
expect(keynote.triggers).toEqual([automation]);
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
});
});
describe('isLoadedPlayable()', () => {
it('returns true when the loaded event still exists and is playable', () => {
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
});
it('returns false when the loaded event was removed', () => {
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
it('returns false when the loaded event is now skipped', () => {
const rundown = makeRundown({
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
});
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
it('returns false when the matched entry is no longer an event', () => {
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
@@ -1,40 +0,0 @@
import type { OntimeGroup } from 'ontime-types';
import { describe, expect, test } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder } from '../rundown.validation.js';
const rundown = makeRundown({
order: ['top', 'group'],
entries: {
top: makeOntimeEvent({ id: 'top', parent: null }),
group: makeOntimeGroup({ id: 'group', entries: ['nested'] }),
nested: makeOntimeEvent({ id: 'nested', parent: 'group' }),
},
});
describe('insertion anchor validation', () => {
test('rejects a missing anchor', () => {
expect(() => assertInsertAnchorInOrder(rundown, null, { before: 'missing' })).toThrow(
'Insertion anchor with ID missing does not exist',
);
});
test('rejects an anchor from a different order', () => {
expect(() => assertInsertAnchorInOrder(rundown, null, { after: 'nested' })).toThrow(
'Insertion anchor with ID nested is not in the target order',
);
});
test('accepts a group-local anchor', () => {
const group = rundown.entries.group as OntimeGroup;
expect(() => assertInsertAnchorInOrder(rundown, group, { before: 'nested' })).not.toThrow();
});
test('checks clone anchors exist', () => {
expect(() => assertInsertAnchorExists(rundown, { after: 'missing' })).toThrow(
'Insertion anchor with ID missing does not exist',
);
});
});
+43 -41
View File
@@ -439,27 +439,21 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
rundown.entries[nestedEntry.id] = nestedEntry; rundown.entries[nestedEntry.id] = nestedEntry;
} }
const position: { after: EntryId | null; before: EntryId | null } = (() => { // by default we insert after the cloned element
if (options?.after === undefined && options?.before === undefined) { let atIndex = rundown.order.indexOf(entry.id) + 1;
return { after: entry.id, before: null };
const referenceId = options?.after ?? options?.before;
if (referenceId) {
// trying to insert relatively to another entry
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry) {
if (options?.after) {
atIndex = rundown.order.indexOf(referenceId) + 1;
} else if (options?.before) {
atIndex = rundown.order.indexOf(referenceId);
}
} }
}
const after = getInsertAfterId(rundown, null, options.after, options.before);
const before = options.before === true ? (after ?? null) : (options.before ?? null);
return { after, before };
})();
const atIndex = (() => {
if (position.before !== null) {
return rundown.order.indexOf(position.before);
}
if (position.after !== null) {
return rundown.order.indexOf(position.after) + 1;
}
return rundown.order.length;
})();
// we only need to insert the group, the nested entries will be resolved by the rundown engine // we only need to insert the group, the nested entries will be resolved by the rundown engine
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order); rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
@@ -467,33 +461,41 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
return newGroup; return newGroup;
} else { } else {
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown)); const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
const parent = (() => {
const referenceId = (() => {
if (typeof options?.after === 'string') return options.after;
if (typeof options?.before === 'string') return options.before;
return undefined;
})();
const referenceEntry = referenceId ? rundown.entries[referenceId] : entry; let parent: OntimeGroup | null = null;
const parentId = referenceEntry && !isOntimeGroup(referenceEntry) ? referenceEntry.parent : null;
if (parentId === null) return null;
const maybeParent = rundown.entries[parentId]; // trying to insert relatively to another entry, check that entries parent
return isOntimeGroup(maybeParent) ? maybeParent : null; const referenceId = options?.after ?? options?.before;
})();
const position = (() => { /**
// if no position is given, we add immediately after the element * if we have a positioning reference, and that reference has a parent
if (options?.after === undefined && options?.before === undefined) { * we need to maintain the same parent for the cloned entry
return { after: entry.id, before: null }; */
if (referenceId) {
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
if (referenceEntry.parent) {
const maybeParent = rundown.entries[referenceEntry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
} }
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
const after = getInsertAfterId(rundown, parent, options.after, options.before); // if we have resolved a parent, we add it to the cloned entry
const before = options?.before === true ? after : (options.before ?? null); let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
return { after, before }; if (!after) {
})(); after = entry.id;
}
return addToRundown(rundown, clonedEntry, parent, position.after, position.before); return addToRundown(rundown, clonedEntry, after, parent);
} }
} }

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