Compare commits

..

12 Commits

Author SHA1 Message Date
Carlos Valente 1a7bc2da25 refactor(automation): trim recipe scaffolding 2026-09-11 20:06:20 +02:00
Carlos Valente 397b54a472 fix(automation): accept single-label OSC hosts 2026-09-11 20:06:01 +02:00
Carlos Valente dc2d193156 feat(automation): streamline automation management 2026-09-11 19:57:38 +02:00
Carlos Valente 0a12a64240 feat(automation): add guided automation recipes 2026-09-11 19:57:25 +02:00
Carlos Valente ab61d3434b feat(automation): label event triggers and outputs clearly 2026-09-11 19:56:56 +02:00
Carlos Valente 4601770d1d feat(automation): create global triggers with automations 2026-09-11 19:56:42 +02:00
Carlos Valente 123692827c feat(automation): support URL-encoded template values 2026-09-11 19:56:31 +02:00
Carlos Valente 1175b3c641 fix(report): harden empty report check 2026-09-05 20:51:53 +02:00
Carlos Valente afc3a44455 refactor(report): improve report summary 2026-09-05 20:35:44 +02:00
Carlos Valente bc6a321172 fix(cuesheet): prevent empty seconds field 2026-09-05 20:32:02 +02:00
Carlos Valente 977b01a072 fix: keep group visible as header for the running event 2026-09-05 09:24:42 +02:00
SMPTY dd718230ad fix(client): fix PWA manifest so "Install as App" works properly (#2192) 2026-09-05 09:15:08 +02:00
122 changed files with 4547 additions and 1659 deletions
+4 -2
View File
@@ -7,13 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" /> <link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" /> <link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>ontime</title> <title>Ontime</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+9 -7
View File
@@ -1,19 +1,21 @@
{ {
"name": "ontime", "name": "Ontime",
"short_name": "ontime", "short_name": "Ontime",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "ontime-logo-192.png",
"type": "image/x-icon" "sizes": "192x192",
"type": "image/png"
}, },
{ {
"src": "ontime-logo.png", "src": "ontime-logo-512.png",
"sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
"display": "", "display": "standalone",
"theme_color": "#121212", "theme_color": "#101010",
"background_color": "#101010" "background_color": "#101010"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

-8
View File
@@ -1,8 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
+1 -4
View File
@@ -3,7 +3,6 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext'; import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets'; import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
@@ -113,9 +112,7 @@ export default function AppRouter() {
path='rundown' path='rundown'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}> <RundownPanel />
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper> </EditorFeatureWrapper>
} }
/> />
+6 -2
View File
@@ -4,6 +4,7 @@ import type {
AutomationDTO, AutomationDTO,
AutomationOutput, AutomationOutput,
AutomationSettings, AutomationSettings,
AutomationTriggerDTO,
Trigger, Trigger,
TriggerDTO, TriggerDTO,
} from 'ontime-types'; } from 'ontime-types';
@@ -57,8 +58,11 @@ export function deleteTrigger(id: string): Promise<void> {
/** /**
* HTTP request to create a new automation * HTTP request to create a new automation
*/ */
export async function addAutomation(automation: AutomationDTO): Promise<Automation> { export async function addAutomation(
const res = await axios.post(`${automationsPath}/automation`, automation); automation: AutomationDTO,
triggers: AutomationTriggerDTO[] = [],
): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, { ...automation, triggers });
return res.data; return res.data;
} }
-8
View File
@@ -22,14 +22,6 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation']; export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
+3 -8
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { OntimeReport } from 'ontime-types'; import type { ReportData } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants'; import { REPORT, apiEntryUrl } from './constants';
@@ -8,18 +8,13 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`; export const reportUrl = `${apiEntryUrl}/report`;
/** /**
* HTTP request to fetch all reports * HTTP request to fetch the report
*/ */
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> { export async function fetchReport(options?: RequestOptions): Promise<ReportData> {
const res = await axios.get(reportUrl, { signal: options?.signal }); const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data; return res.data;
} }
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() { export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`); await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
@@ -0,0 +1,25 @@
import { TimerLifeCycle } from 'ontime-types';
/**
* User facing labels for the timer lifecycle
* Shared between the automation settings and the rundown event editor
* so that a lifecycle is named the same everywhere it is shown
*/
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
[TimerLifeCycle.onLoad]: 'On Load',
[TimerLifeCycle.onStart]: 'On Start',
[TimerLifeCycle.onPause]: 'On Pause',
[TimerLifeCycle.onStop]: 'On Stop',
[TimerLifeCycle.onClock]: 'Every second',
[TimerLifeCycle.onUpdate]: 'On Timer Update',
[TimerLifeCycle.onFinish]: 'On Finish',
[TimerLifeCycle.onWarning]: 'On Warning',
[TimerLifeCycle.onDanger]: 'On Danger',
};
/**
* Resolves a lifecycle to its user facing label, falling back to the raw value
*/
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}
@@ -1,24 +0,0 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -1,77 +0,0 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -1,81 +0,0 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Rundown data for surfaces which only ever work against the rundown being played:
* the viewers, the operator, app settings and the runtime overview.
*
* These take no part in a rundown scope, they resolve the loaded rundown directly.
* Anything which can be pointed at a background rundown reads from its scope instead.
*/
export function useLoadedRundown() {
const {
data: { loaded },
} = useProjectRundowns();
return useRundownById(loaded);
}
export function useLoadedRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
export function useLoadedFlatRundown() {
const { data, status } = useLoadedRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useLoadedFlatRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*/
export function useLoadedPartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
@@ -1,17 +1,34 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types'; import type { ReportData } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils'; import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants'; import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report'; import { fetchReport } from '../api/report';
const emptyReport: ReportData = {
eventReports: {},
rundown: null,
show: {
plannedStart: null,
plannedEnd: null,
plannedDuration: null,
actualStart: null,
actualEnd: null,
actualDuration: null,
},
};
export default function useReport() { export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({ const { data: report, refetch } = useQuery<ReportData>({
queryKey: REPORT, queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }), queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData) => previousData,
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
return { data: data ?? {}, refetch }; return {
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
} }
+100 -31
View File
@@ -1,38 +1,62 @@
import { EntryId, OntimeEntry } from 'ontime-types'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useRundownScope } from '../context/RundownScopeContext'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById'; import { useProjectRundowns } from './useProjectRundowns';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/** /**
* Normalised rundown data for the rundown of the enclosing scope * Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export default function useRundown() {
const { rundownId } = useRundownScope(); const queryClient = useQueryClient();
return useRundownById(rundownId); const {
} data: { loaded: loadedRundownId },
} = useProjectRundowns();
/** const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
* Runtime state only describes the loaded rundown, queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
* a scope pointed elsewhere must not show a playing event queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
*/ refetchInterval: queryRefetchIntervalSlow,
export function useScopedSelectedEventId(): EntryId | null { });
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId(); // Seed the id-keyed cache when fetching via the bootstrap alias
return isLoaded ? selectedEventId : null; useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId(); const selectedEventId = useSelectedEventId();
// key on the fields the derivation reads, a revision only change must not churn the list const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -42,23 +66,41 @@ export function useRundownWithMetadata() {
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId(); const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data; const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
} }
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/** /**
* Hook to get a specific entry by ID from the rundown * Hook to get a specific entry by ID from the rundown
*/ */
@@ -68,3 +110,30 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId] ?? null; return rundown.entries[entryId] ?? null;
} }
export function useRundownAuxData() {
const { data, status } = useRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -1,66 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
+18 -47
View File
@@ -8,6 +8,7 @@ import {
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
PatchWithId, PatchWithId,
ProjectRundownsList,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeField, TimeField,
@@ -31,10 +32,9 @@ import {
swapEventData, swapEventData,
} from 'ontime-utils'; } from 'ontime-utils';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { getRundownCacheKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import { import {
ReorderEntry, ReorderEntry,
deleteEntries, deleteEntries,
@@ -52,7 +52,6 @@ import {
requestFitGroupTarget, requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
@@ -65,38 +64,22 @@ export type EventOptions = Partial<{
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
/**
* Applies a patch the way the server does, revision included.
*
* An entry revision advances on every change to that entry, which makes it a
* cheap marker for whether an entry has moved on. The optimistic entry has to
* carry the revision the server will return, otherwise the two disagree and the
* refetch resolves to a different object for no reason.
* Mirrors applyPatchToEntry in the rundown service: delays carry no revision.
*/
function patchEntry(entry: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) {
return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry;
}
return { ...entry, ...patch } as OntimeEntry;
}
type ClientInsertOptions = { type ClientInsertOptions = {
after?: EntryId; after?: EntryId;
before?: EntryId; before?: EntryId;
}; };
/** /**
* Gather utilities for actions on entries in the rundown of the enclosing scope. * Gather utilities for actions on entries in the loaded rundown.
*/ */
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId); export const useEntryActions = () => useEntryActionsForRundown(undefined);
/** /**
* Gather utilities for actions on entries in an explicitly selected rundown. * Gather utilities for actions on entries in an explicitly selected rundown.
*/ */
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? ''); export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string) { function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
linkPrevious, linkPrevious,
@@ -110,8 +93,12 @@ function useEntryActionsForRundown(scopedRundownId: string) {
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
return getRundownCacheKey(scopedRundownId); if (scopedRundownId !== undefined) {
}, [scopedRundownId]); return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/** /**
* Returns the currently loaded rundown * Returns the currently loaded rundown
@@ -338,10 +325,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.entries }; const newRundown = { ...previousData.entries };
const previousEntry = newRundown[eventId]; // @ts-expect-error -- we expect the events to be of same type
if (previousEntry) { newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
queryClient.setQueryData<Rundown>(queryKey, { queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
@@ -355,23 +340,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData, newEvent, queryKey }; return { previousData, newEvent, queryKey };
}, },
// the server is the authority on the applied patch, it may normalise what we sent
onSuccess: (response, _variables, context) => {
const serverEntry = response.data;
if (!serverEntry || !context?.queryKey) return;
const cachedRundown = queryClient.getQueryData<Rundown>(context.queryKey);
if (!cachedRundown) return;
// our optimistic entry usually describes the change exactly, writing an
// identical entry would discard the cached reference for nothing
if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return;
queryClient.setQueryData<Rundown>(context.queryKey, {
...cachedRundown,
entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry },
});
},
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) { if (context?.previousData && context?.queryKey) {
@@ -544,7 +512,10 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId]; const event = newRundown[eventId];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
newRundown[eventId] = patchEntry(event, data.data); newRundown[eventId] = {
...event,
...data,
};
} }
} }
}); });
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef>, scrollRef: RefObject<ScrollRef | null>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,18 +21,16 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; followTrigger: MaybeString; // this would be an entry id or null
setScrollFlag?: (newValue: boolean) => void; getTopOffset: () => number;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -41,25 +39,17 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset); // resolve the offset after layout, so that measured values are up to date
setScrollFlag?.(false); scrollToComponent(followRef, scrollRef, getTopOffset());
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); }, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(() => {
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { scrollToComponent(followRef, scrollRef, getTopOffset());
if (componentRef && containerRef) { }, [followRef, scrollRef, getTopOffset]);
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/ */
export function useWindowTitle(title: string) { export function useWindowTitle(title: string) {
useEffect(() => { useEffect(() => {
document.title = `ontime - ${title}`; document.title = `Ontime - ${title}`;
}, []); }, []);
} }
@@ -1,64 +0,0 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -1,21 +1,14 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: MaybeString; entryCopyId: string | null;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut'; entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void; setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
}; };
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy', entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') => setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }), set({ entryCopyId, entryCopyMode: mode }),
})); }));
@@ -1,158 +0,0 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
import { summariseOutputs } from '../automationOutputs';
describe('summariseOutputs', () => {
it('returns an empty list when there are no outputs', () => {
expect(summariseOutputs([])).toEqual([]);
});
it('counts repeated output kinds', () => {
const outputs: AutomationOutput[] = [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
{ type: 'http', url: 'http://127.0.0.1/start' },
];
expect(summariseOutputs(outputs)).toEqual([
{ type: 'osc', label: 'OSC', count: 2 },
{ type: 'http', label: 'HTTP', count: 1 },
]);
});
it('presents kinds in a stable order regardless of insertion order', () => {
const outputs: AutomationOutput[] = [
{ type: 'ontime', action: 'aux1-start' },
{ type: 'http', url: 'http://127.0.0.1/start' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
];
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
});
});
@@ -0,0 +1,23 @@
import type { OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getEventVariance } from '../report';
it('uses captured days when measuring an event across midnight', () => {
const report: OntimeEventReport = {
startedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 5 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
};
expect(getEventVariance(report)).toMatchObject({
actualDuration: 10 * MILLIS_PER_MINUTE,
delta: 0,
status: 'ontime',
});
expect(getEventVariance({ ...report, endedAt: null })).toMatchObject({ status: 'not-run' });
});
@@ -58,4 +58,16 @@ describe('formatDuration()', () => {
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s'); expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s'); expect(formatDuration(599702, false)).toBe('9m59s');
}); });
it('formats durations differently with and without seconds', () => {
expect(formatDuration(0, false)).toBe('0m');
expect(formatDuration(0, true)).toBe('0m');
expect(formatDuration(30 * MILLIS_PER_SECOND, false)).toBe('30s');
expect(formatDuration(30 * MILLIS_PER_SECOND, true)).toBe('');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, false)).toBe('2h30s');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, true)).toBe('2h');
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, false)).toBe(
'2h10m30s',
);
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, true)).toBe('2h10m');
});
}); });
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
const outputLabels: Record<AutomationOutput['type'], string> = {
osc: 'OSC',
http: 'HTTP',
ontime: 'Ontime',
};
export type OutputSummary = {
type: AutomationOutput['type'];
label: string;
count: number;
};
/**
* Summarises an automation's outputs by kind so that a list row can say what the
* automation does without the user having to open the form.
* Shared between the automation settings panel and the rundown event editor.
*/
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
// keep a stable presentation order regardless of the order the user added outputs
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
return order
.filter((type) => counts.has(type))
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
}
+29
View File
@@ -0,0 +1,29 @@
import type { MaybeNumber, OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
type EventVariance = {
actualDuration: MaybeNumber;
delta: number;
status: 'ontime' | 'over' | 'under' | 'not-run';
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
export function getReportTimePosition(time: number, day: number): number;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber {
return time === null || day === null ? null : day * dayInMs + time;
}
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) return notRun;
const start = getReportTimePosition(entry.startedAt, entry.startedAtDay);
const end = getReportTimePosition(entry.endedAt, entry.endedAtDay);
if (start === null || end === null) return notRun;
const actualDuration = end - start;
const delta = actualDuration - entry.scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) return { actualDuration, delta, status: 'ontime' };
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return; return;
} }
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId); const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
// we already have this change, or something newer if (revision === cachedRundown?.revision) {
// messages can arrive after a refetch has already brought in a later revision // we already have the latest change
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
return; return;
} }
@@ -1,13 +1,27 @@
/**
* The wide modal body does not scroll, so the form owns it.
* Without this the form is simply clipped: four outputs is enough to put Save out of reach.
*/
.form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.formScroll {
height: 100%;
}
.outerColumn { .outerColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
color: $ui-white; color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem; padding-block: 0.5rem;
// leaves the overlay scrollbar somewhere to sit without covering a field
padding-right: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -26,61 +40,84 @@
gap: 1rem; gap: 1rem;
} }
.titleSection, .titleSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-gap: 0.5rem; grid-gap: 0.5rem;
button {
align-self: flex-end;
}
} }
.titleSection, .titleSection,
.ruleSection, .ruleSection,
.filterSection, .card {
.oscSection,
.httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element
font-size: calc(1rem - 3px);
}
label { label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray; color: $label-gray;
} }
} }
.titleSection { /** shared shell for a single filter or output */
grid-template-columns: 1fr; .card {
} border: 1px solid $white-10;
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard {
border-left: 0.25rem solid $gray-1200; border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem; border-radius: $component-border-radius-md;
background-color: $black-10;
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
/** pushes the actions to the end of the header, and absorbs any overflow */
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.cardBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
/** for fields that read badly when narrow: OSC address and args, URLs, message text */
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: $aux-text-size;
color: $green-400;
}
.testError {
padding: 0 0.75rem 0.5rem;
}
.tagOsc {
background-color: $blue-1000;
color: $blue-300;
}
.tagHttp {
background-color: $green-1000;
color: $green-300;
}
.tagOntime {
background-color: $gray-1000;
color: $gray-200;
} }
@@ -1,53 +1,103 @@
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
HTTPOutput, AutomationFilter,
OSCOutput, TimerLifeCycle,
OntimeAction, Trigger,
isHTTPOutput, isHTTPOutput,
isOSCOutput, isOSCOutput,
isOntimeAction, isOntimeAction,
} from 'ontime-types'; } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; import {
addAutomation,
addTrigger,
deleteTrigger,
editAutomation,
testOutput,
} from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
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 RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils'; import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
import TemplateInput from './template-input/TemplateInput'; import OscOutputForm from './OscOutputForm';
import OutputCard, { type TestState } from './OutputCard';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form'; const formId = 'automation-form';
/** how long a successful test keeps its confirmation on screen */
const testFeedbackDuration = 2000;
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
triggers: Trigger[];
onClose: () => void; onClose: () => void;
} }
export default function AutomationForm({ automation, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
/**
* The triggers the server holds for this automation, as far as this form knows.
*
* Seeded from a snapshot taken when the form opens, never from the live prop: settings are
* polled, so a trigger created elsewhere while this form is open must not be deleted by a
* save that never saw it. It then advances as each request succeeds, so a save that fails
* half way leaves only the outstanding work for the retry.
*/
const [syncedTriggers, setSyncedTriggers] = useState<Trigger[]>(() =>
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
);
const syncedCycles = useMemo(
() => Array.from(new Set(syncedTriggers.map((trigger) => trigger.trigger))),
[syncedTriggers],
);
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
const cyclesAreDirty =
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.includes(cycle));
const toggleCycle = (cycle: TimerLifeCycle) => {
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
};
const triggersToRemove = syncedTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
/**
* Test results are keyed by the field array id rather than the index:
* removing an output shifts every index after it, which would leave feedback on the wrong row
*/
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const { const {
clearErrors,
control, control,
handleSubmit, handleSubmit,
getValues, getValues,
@@ -60,10 +110,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
} = useForm<AutomationDTO>({ } = useForm<AutomationDTO>({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
title: automation?.title ?? '', title: automation.title,
filterRule: automation?.filterRule ?? 'all', filterRule: automation.filterRule,
filters: automation?.filters ?? [], filters: automation.filters,
outputs: automation?.outputs ?? [], outputs: automation.outputs,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -93,6 +143,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title'); setFocus('title');
}, [setFocus]); }, [setFocus]);
// the timers outlive a fast close, clearing them avoids setting state on an unmounted form
useEffect(() => {
const timers = feedbackTimers.current;
return () => Object.values(timers).forEach(clearTimeout);
}, []);
const reportTest = (key: string, state: TestState) => {
setTestResults((prev) => ({ ...prev, [key]: state }));
clearTimeout(feedbackTimers.current[key]);
if (state.status === 'ok') {
feedbackTimers.current[key] = setTimeout(() => {
setTestResults((prev) => {
const { [key]: _discarded, ...rest } = prev;
return rest;
});
}, testFeedbackDuration);
}
};
const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
const handleAddNewFilter = () => { const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' }); appendFilter({ field: '', operator: 'equals', value: '' });
}; };
@@ -106,84 +178,114 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' }); appendOutput({ type: 'http', url: '' });
}; };
const handleAddnewOntimeAction = () => { const handleAddNewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' }); appendOutput({ type: 'ontime', action: 'aux1-start' });
}; };
const handleTestOSCOutput = async (index: number) => { /**
* Sends a single output as configured, without saving the automation.
* OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
*/
const handleTest = async (index: number, key: string) => {
const values = getValues(`outputs.${index}`);
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
return;
}
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
reportTest(key, { status: 'sending' });
try { try {
const values = getValues(`outputs.${index}`) as OSCOutput; // NOTE: there is no meaningful validation to do on an Ontime action, we let the server deal with the data
if (!values.targetIP || !values.targetPort || !values.address) { await testOutput(values);
return; reportTest(key, { status: 'ok', message: 'Sent' });
} } catch (error) {
await testOutput({ reportTest(key, { status: 'error', message: maybeAxiosError(error) });
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
} }
}; };
const handleTestHTTPOutput = async (index: number) => { /**
try { * Reconciles an existing automation's lifecycle selection against the global triggers.
const values = getValues(`outputs.${index}`) as HTTPOutput; * New automations and their triggers are created together in one request.
if (!values.url) { *
return; * Every request advances the synced snapshot as it succeeds, so pressing save again after
} * a failure half way through retries only what is left. Without that a retry would re-add
await testOutput({ * a trigger it already created, and re-delete one it already deleted, which the server
type: 'http', * rejects outright.
url: values.url, */
}); const syncTriggers = async (automationId: string, title: string) => {
} catch (_error) { for (const trigger of triggersToRemove) {
/** we dont handle errors here, users should use the network tab */ await deleteTrigger(trigger.id);
setSyncedTriggers((prev) => prev.filter((synced) => synced.id !== trigger.id));
} }
};
const handleTestOntimeAction = async (index: number) => { const toAdd = selectedCycles.filter((cycle) => !syncedCycles.includes(cycle));
try { for (const cycle of toAdd) {
const values = getValues(`outputs.${index}`) as OntimeAction; const created = await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId });
// NOTE: there is no meaningful validation to do here, we let the server deal with the data setSyncedTriggers((prev) => [...prev, created]);
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
} }
}; };
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) { // a stale failure from the previous attempt would otherwise sit under a successful retry
await handleEdit(automation.id, { id: automation.id, ...values }); clearErrors('root');
} else {
await handleCreate(values); try {
if (!isAutomation(automation)) {
await addAutomation(
values,
selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })),
);
refetch();
onClose();
return;
}
await editAutomation(automation.id, { id: automation.id, ...values });
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
return;
} }
try {
await syncTriggers(automation.id, values.title);
} catch (error) {
// the automation itself is saved, only its triggers failed. Keep the form open so the user can retry
refetch();
setError('root', { message: `Automation saved, but its triggers failed: ${maybeAxiosError(error)}` });
return;
}
refetch(); refetch();
onClose();
async function handleEdit(id: string, values: Automation) {
try {
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
async function handleCreate(values: AutomationDTO) {
try {
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
}; };
const canSubmit = !isSubmitting && isDirty && isValid; const describeFilter = (index: number): string | null => {
const field = watch(`filters.${index}.field`);
if (!field) {
return null;
}
const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field;
const operator = watch(`filters.${index}.operator`);
const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator;
const value = watch(`filters.${index}.value`);
return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`;
};
/**
* A failed save reports itself as a root error, which react-hook-form counts against
* isValid. Left alone that disables the very retry the message is asking the user to make,
* so a root error on its own does not block submitting again.
*/
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0);
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
return ( return (
<Modal <Modal
@@ -191,304 +293,238 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
onClose={onClose} onClose={onClose}
showBackdrop showBackdrop
showCloseButton showCloseButton
size='wide'
title={isEdit ? 'Edit automation' : 'Create automation'} title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={ bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<div className={style.innerColumn}> <ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
<h3>Automation options</h3> <div className={style.innerColumn}>
<div className={style.titleSection}> <h3>Automation options</h3>
<label> <div className={style.titleSection}>
Title <label>
<Input Title
{...register('title', { required: { value: true, message: 'Required field' } })} <Input
fluid {...register('title', { required: { value: true, message: 'Required field' } })}
placeholder='Load preset' fluid
/> placeholder='Load preset'
</label> />
<Panel.Error>{errors.title?.message}</Panel.Error> </label>
</div> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </div>
<div className={style.innerColumn}> <div className={style.titleSection}>
<h3>Filters (optional)</h3> <label id='runs-on-label'>Runs on</label>
<div className={style.ruleSection}> <Panel.Description>
<label> Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
Trigger outputs if single event from the event editor.
<RadioGroup </Panel.Description>
orientation='horizontal' <Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
value={watch('filterRule')} {cycles.map(({ label, value }) => {
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })} const isSelected = selectedCycles.includes(value);
items={[ return (
{ value: 'all', label: 'All filters pass' }, <Button
{ value: 'any', label: 'Any filter passes' }, key={value}
]} size='small'
/> variant={isSelected ? 'primary' : 'subtle'}
</label> aria-pressed={isSelected}
{fieldFilters.map((field, index) => { onClick={() => toggleCycle(value)}
const key = `filters.${index}.field.${field.id}`; >
return ( {label}
<div key={key} className={style.filterSection}> </Button>
<label> );
Runtime data source })}
<Select<string | null> </Panel.InlineElements>
// need to normalize '' to null for the Select to show the placeholder {hasContinuousCycle && (
value={watch(`filters.${index}.field`) || null} <Panel.Description tone='warning'>
onValueChange={(value) => { Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
if (value === null) return; mean to send on every tick.
setValue(`filters.${index}.field`, value, { shouldDirty: true }); </Panel.Description>
}} )}
options={fieldList.map(({ value, label }) => ({ {triggersToRemove.length > 0 && (
value, <Panel.Description tone='warning'>
label, {`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove
disabled: value === null, .map((trigger) => trigger.title)
}))} .join(', ')}`}
aria-label='Event field' </Panel.Description>
/> )}
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> </div>
</label> </div>
<label>
Matching condition <div className={style.innerColumn}>
<Select <h3>Filters (optional)</h3>
value={watch(`filters.${index}.operator`)} <Panel.Description>
onValueChange={(value: string | null) => { Without filters the outputs are sent every time the automation is triggered.
if (value === null) return; </Panel.Description>
setValue( <div className={style.ruleSection}>
`filters.${index}.operator`, {fieldFilters.length > 1 && (
value as <label>
| 'equals' Trigger outputs if
| 'not_equals' <RadioGroup
| 'greater_than' orientation='horizontal'
| 'less_than' value={watch('filterRule')}
| 'contains' onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
| 'not_contains', items={[
{ shouldDirty: true }, { value: 'all', label: 'All filters pass' },
); { value: 'any', label: 'Any filter passes' },
}} ]}
options={[ />
{ value: 'equals', label: 'equals' }, </label>
{ value: 'not_equals', label: 'not equals' }, )}
{ value: 'contains', label: 'contains' }, {fieldFilters.map((field, index) => {
]} const description = describeFilter(index);
aria-label='Operator' return (
/> <div key={field.id} className={style.card}>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> <div className={style.cardHeader}>
</label> <Tag>Filter</Tag>
<label> <span className={style.cardSummary}>{description}</span>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton <IconButton
aria-label='Delete' aria-label='Delete filter'
variant='ghosted-destructive' variant='ghosted-destructive'
onClick={() => removeFilter(index)} onClick={() => removeFilter(index)}
> >
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</div> </div>
<div className={style.cardBody}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
shouldDirty: true,
});
}}
options={operators}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
</div>
</div> </div>
</div> );
); })}
})} <div>
<div> <Button onClick={handleAddNewFilter}>
<Button onClick={handleAddNewFilter}> Add filter <IoAdd />
Add filter <IoAdd /> </Button>
</Button> </div>
</div> </div>
</div> </div>
</div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
or to change properties of Ontime itself. <br /> <br /> <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} </Info>
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.length === 0 && (
if (isOSCOutput(output)) { <Panel.EmptyState
const rowErrors = errors.outputs?.[index] as title='This automation does nothing yet'
| { description='An automation without outputs will be triggered, but it has nothing to send.'
targetIP?: { message?: string }; />
targetPort?: { message?: string }; )}
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return ( {fieldOutputs.map((output, index) => {
<div key={output.id} className={style.outputCard}> const rowErrors = getOutputErrors(index);
<Tag>OSC</Tag> const cardProps = {
<div className={style.oscSection}> testState: testResults[output.id],
<label> onTest: () => handleTest(index, output.id),
Target IP onDelete: () => removeOutput(index),
<Input };
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isOntimeAction(output)) { if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as return (
| { <OutputCard
action?: { message?: string }; key={output.id}
time?: { message?: string }; label='OSC'
text?: { message?: string }; kindClass={style.tagOsc}
visible?: { message?: string }; summary={watch(`outputs.${index}.address`)}
secondarySource?: { message?: string }; {...cardProps}
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
> >
<span>&nbsp;</span> <OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
<Panel.InlineElements relation='inner'> </OutputCard>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}> );
Test }
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
return null; if (isHTTPOutput(output)) {
})} return (
<Panel.InlineElements relation='inner'> <OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<Button onClick={handleAddNewOSCOutput}> <HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
OSC <IoAdd /> </OutputCard>
</Button> );
<Button onClick={handleAddNewHTTPOutput}> }
HTTP <IoAdd />
</Button> if (isOntimeAction(output)) {
<Button onClick={handleAddnewOntimeAction}> return (
Ontime action <IoAdd /> <OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
</Button> <OntimeActionForm
</Panel.InlineElements> value={output.action}
</div> index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
/>
</OutputCard>
);
}
return null;
})}
<div>
<DropdownMenu
render={<Button />}
items={[
{
type: 'item',
label: 'OSC',
description: 'Send an OSC message to a device on the network',
onClick: handleAddNewOSCOutput,
},
{
type: 'item',
label: 'HTTP',
description: 'Call a URL, for webhooks and REST APIs',
onClick: handleAddNewHTTPOutput,
},
{
type: 'item',
label: 'Ontime action',
description: 'Change something inside Ontime, like a message or an aux timer',
onClick: handleAddNewOntimeAction,
},
]}
>
Add output <IoAdd />
</DropdownMenu>
</div>
</div>
</ScrollArea>
</form> </form>
} }
footerElements={ footerElements={
@@ -30,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
/> />
</div> </div>
<div ref={automationsRef}> <div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} /> <AutomationsList
</div>
<div ref={triggersRef}>
<TriggersList
triggers={data.triggers}
automations={data.automations} automations={data.automations}
triggers={data.triggers}
enabledAutomations={automationState} enabledAutomations={automationState}
isLoading={isLoading} isLoading={isLoading}
/> />
</div> </div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
</div>
</> </>
); );
} }
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
<Panel.Section> <Panel.Section>
<Info> <Info>
<span>Control Ontime and share its data with external systems in your workflow.</span> <span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span> <span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info> </Info>
@@ -0,0 +1,20 @@
/** tags make a cell taller than the title beside it, which staggers on the default baseline */
.table td {
vertical-align: middle;
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.actions {
justify-content: flex-end;
flex-wrap: nowrap;
}
.muted {
color: $muted-gray;
}
@@ -1,18 +1,25 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog';
const automationPlaceholder: AutomationDTO = { import style from './AutomationsList.module.scss';
const emptyAutomation: AutomationDTO = {
title: '', title: '',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
@@ -21,37 +28,73 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
triggers: Trigger[];
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean; isLoading: boolean;
} }
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) { export default function AutomationsList({
automations,
triggers,
enabledAutomations,
isLoading,
}: AutomationsListProps) {
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const { setLocation } = useAppSettingsNavigation();
const [deleteError, setDeleteError] = useState<string | null>(null); const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
const [isPickingStart, setIsPickingStart] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
const handleDelete = async (id: string) => { const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
try { const automationList = Object.values(automations);
setDeleteError(null);
await deleteAutomation(id); /** a recipe creates the automation itself, so it lands in the list rather than in a form */
} catch (error) { const handleCreated = async () => {
setDeleteError(maybeAxiosError(error)); setIsPickingStart(false);
} finally { await refetch();
refetch();
}
}; };
const arrayAutomations = Object.keys(automations); const handleStartEmpty = () => {
setIsPickingStart(false);
setEditing(emptyAutomation);
};
const handleDeleted = async () => {
setDeleteTarget(null);
await refetch();
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{automationFormData !== null && ( {editing !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} /> <AutomationForm
// the form snapshots the automation's lifecycles on mount, so it must never be
// reused across two different automations
key={isAutomation(editing) ? editing.id : 'new'}
automation={editing}
triggers={triggers}
onClose={() => setEditing(null)}
/>
)}
{isPickingStart && (
<NewAutomationDialog
onClose={() => setIsPickingStart(false)}
onStartEmpty={handleStartEmpty}
onCreated={handleCreated}
/>
)}
{deleteTarget !== null && (
<DeleteAutomationDialog
automation={deleteTarget}
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
)} )}
<Panel.SubHeader> <Panel.SubHeader>
Manage automations Manage automations
<Button onClick={() => setAutomationFormData(automationPlaceholder)}> <Button onClick={() => setIsPickingStart(true)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -60,74 +103,101 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section> <Panel.Section>
{enabledAutomations === false && ( {enabledAutomations === false && (
<Info> <Info type='warning'>
Automations are disabled. You can still manage automation definitions here, but they will not run until <Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
enabled. <Info.Footer>
{/* the master switch is at the top of the panel, out of sight once the list has rows */}
<Button size='small' onClick={() => setLocation('automation__settings')}>
Go to automation settings
</Button>
</Info.Footer>
</Info> </Info>
)} )}
<Panel.Table> <Panel.Table className={style.table}>
<thead> <thead>
<tr> <tr>
<th style={{ width: '45%' }}>Title</th> <th style={{ width: '35%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th> <th style={{ width: '25%' }}>Runs on</th>
<th style={{ width: '15%' }}>Filters</th> <th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Outputs</th> <th style={{ width: '15%' }}>Sends</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!isLoading && arrayAutomations.length === 0 && ( {!isLoading && automationList.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
title='No automations yet' title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.' description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
action={ action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}> <Button variant='primary' onClick={() => setIsPickingStart(true)}>
Create automation <IoAdd /> New automation <IoAdd />
</Button> </Button>
} }
/> />
)} )}
{arrayAutomations.map((automationId) => { {automationList.map((automation) => {
if (!Object.hasOwn(automations, automationId)) { const lifecycles = lifecyclesByAutomation[automation.id] ?? [];
return null; const outputs = summariseOutputs(automation.outputs);
}
return ( return (
<Fragment key={automationId}> <tr key={automation.id}>
<tr> <td>{automation.title}</td>
<td>{automations[automationId].title}</td> <td>
<td> {/*
<Tag>{automations[automationId].filterRule}</Tag> * Only global triggers are visible here: an automation can also be attached to
</td> * single events, which live in the rundown. An empty cell is therefore not the
<td>{automations[automationId].filters.length}</td> * same as never running, so it says nothing rather than claiming that.
<td>{automations[automationId].outputs.length}</td> */}
<Panel.InlineElements align='end' relation='inner' as='td'> {lifecycles.length === 0 ? (
<span className={style.muted}></span>
) : (
<div className={style.tags}>
{lifecycles.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
</div>
)}
</td>
<td>
{automation.filters.length === 0 ? (
<span className={style.muted}></span>
) : (
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
)}
</td>
<td>
<div className={style.tags}>
{outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
outputs.map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</div>
</td>
<td>
<div className={cx([style.tags, style.actions])}>
<IconButton <IconButton
variant='ghosted-white' variant='ghosted-white'
aria-label='Edit entry' aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])} onClick={() => setEditing(automation)}
> >
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton <IconButton
variant='ghosted-destructive' variant='ghosted-destructive'
aria-label='Delete entry' aria-label='Delete entry'
onClick={() => handleDelete(automationId)} onClick={() => setDeleteTarget(automation)}
> >
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</Panel.InlineElements> </div>
</tr> </td>
</Fragment> </tr>
); );
})} })}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Section> </Panel.Section>
@@ -0,0 +1,96 @@
import type { Automation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import * as Panel from '../../panel-utils/PanelUtils';
interface DeleteAutomationDialogProps {
automation: Automation;
/** global triggers pointing at this automation, they are deleted along with it */
attachedTriggers: Trigger[];
onCancel: () => void;
onDeleted: () => void;
}
/**
* Deleting takes the automation's global triggers with it, so say so before it happens rather
* than leaving the user to discover it in the triggers list.
*
* An automation attached to an event is still refused by the server: that reference lives in
* the rundown and removing it is an edit to the show, not to this panel.
*/
export default function DeleteAutomationDialog({
automation,
attachedTriggers,
onCancel,
onDeleted,
}: DeleteAutomationDialogProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setError(null);
setIsDeleting(true);
try {
await deleteAutomation(automation.id);
onDeleted();
} catch (error) {
setError(maybeAxiosError(error));
} finally {
setIsDeleting(false);
}
};
return (
<Dialog
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title='Delete automation'
bodyElements={
<Panel.Section>
<Panel.Paragraph>
Delete <strong>{automation.title}</strong>? This cannot be undone.
</Panel.Paragraph>
{attachedTriggers.length > 0 && (
<Info type='warning'>
<Info.Title>
{attachedTriggers.length === 1
? 'Its trigger is deleted with it'
: `Its ${attachedTriggers.length} triggers are deleted with it`}
</Info.Title>
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
</Info>
)}
{error && (
<Info type='error'>
<Info.Title>Could not delete this automation</Info.Title>
<Info.Body>{error}</Info.Body>
<Info.Footer>
An automation attached to a single event has to be removed from that event first, in the event editor.
</Info.Footer>
</Info>
)}
</Panel.Section>
}
footerElements={
<>
<Button onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
Delete
</Button>
</>
}
/>
);
}
@@ -0,0 +1,34 @@
import type { AutomationDTO, HTTPOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface HttpOutputFormProps {
index: number;
output: HTTPOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
return (
<label className={style.spanFull}>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
);
}
@@ -0,0 +1,182 @@
.picker {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: $ui-white;
}
.search {
position: relative;
display: flex;
align-items: center;
padding-block: 0.5rem;
}
.listViewport {
height: auto;
max-height: min(52vh, 30rem);
}
.list {
display: flex;
flex-direction: column;
padding-right: 0.5rem;
}
.searchIcon {
position: absolute;
left: 0.625rem;
color: $gray-400;
pointer-events: none;
}
.searchInput {
padding-left: 2rem;
padding-right: 2rem;
}
.searchClear {
position: absolute;
right: 0.25rem;
}
.group {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-top: 0.75rem;
&:first-child {
padding-top: 0;
}
}
.groupTitle {
margin: 0;
padding-inline: 0.125rem;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: $gray-400;
}
.recipe {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
text-align: left;
color: inherit;
background-color: transparent;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:hover {
background-color: $white-3;
border-color: $white-10;
}
&:focus-visible {
outline: 1px solid $action-blue;
outline-offset: -1px;
}
}
.recipeText {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.recipeTitle {
font-weight: 600;
}
.recipeDescription {
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.recipeTags {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.chevron {
color: $gray-400;
}
.setup {
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
padding-block: 0.25rem;
}
.setupDescription {
margin: 0;
color: $secondary-text-gray;
}
.summary {
display: grid;
grid-template-columns: 5rem 1fr;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0;
padding: 0.75rem;
background-color: $black-10;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
dt {
font-size: $aux-text-size;
color: $label-gray;
}
dd {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
}
}
.fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: 0.75rem;
@media (width < 40rem) {
grid-template-columns: repeat(2, 1fr);
}
}
.wide {
grid-column: 1 / -1;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: $aux-text-size;
color: $label-gray;
}
.hint {
color: $secondary-text-gray;
}
.apart {
margin-right: auto;
}
@@ -0,0 +1,287 @@
import type { Automation } from 'ontime-types';
import { useState, type KeyboardEvent } from 'react';
import { IoAdd, IoArrowBack, IoChevronForward, IoClose, IoSearch } from 'react-icons/io5';
import { addAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import {
defaultValues,
getAvailableRecipes,
recipeCategoryLabels,
recipeCategoryOrder,
type AutomationRecipe,
type RecipeValues,
validateRecipeValues,
} from './automationRecipes';
import { makeTriggerTitle } from './automationUtils';
import style from './NewAutomationDialog.module.scss';
const availableRecipes = getAvailableRecipes(Boolean(isOntimeCloud));
interface NewAutomationDialogProps {
onClose: () => void;
onStartEmpty: () => void;
onCreated: (automation: Automation) => void;
}
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
return selected === null ? (
<RecipePicker onClose={onClose} onStartEmpty={onStartEmpty} onSelect={setSelected} />
) : (
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
);
}
function matches(recipe: AutomationRecipe, query: string): boolean {
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
.join(' ')
.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.every((term) => haystack.includes(term));
}
interface RecipePickerProps {
onClose: () => void;
onStartEmpty: () => void;
onSelect: (recipe: AutomationRecipe) => void;
}
function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
const [query, setQuery] = useState('');
const trimmed = query.trim();
const results = trimmed ? availableRecipes.filter((recipe) => matches(recipe, trimmed)) : availableRecipes;
const handleSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
// the dialog is the only thing listening for escape, and losing it while clearing a
// search would be a bigger surprise than the search staying put
if (event.key === 'Escape' && trimmed.length > 0) {
event.stopPropagation();
setQuery('');
return;
}
if (event.key === 'Enter' && results.length > 0) {
onSelect(results[0]);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='New automation'
bodyElements={
<div className={style.picker}>
<div className={style.search}>
<IoSearch className={style.searchIcon} />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKey}
placeholder='Search recipes, eg. QLab, OSC, message'
className={style.searchInput}
aria-label='Search recipes'
fluid
autoFocus
/>
{trimmed.length > 0 && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.searchClear}
onClick={() => setQuery('')}
>
<IoClose />
</IconButton>
)}
</div>
{results.length === 0 && (
<Panel.EmptyState
title='No recipe matches that'
description='Try the name of the software, or start from an empty automation.'
/>
)}
<ScrollArea viewportClassName={style.listViewport} contentClassName={style.list}>
{recipeCategoryOrder.map((category) => {
const inCategory = results.filter((recipe) => recipe.category === category);
if (inCategory.length === 0) {
return null;
}
return (
<section key={category} className={style.group}>
<h4 className={style.groupTitle}>{recipeCategoryLabels[category]}</h4>
{inCategory.map((recipe) => (
<button type='button' key={recipe.id} className={style.recipe} onClick={() => onSelect(recipe)}>
<div className={style.recipeText}>
<div className={style.recipeTitle}>{recipe.title}</div>
<div className={style.recipeDescription}>{recipe.description}</div>
</div>
<div className={style.recipeTags}>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
<IoChevronForward className={style.chevron} />
</div>
</button>
))}
</section>
);
})}
</ScrollArea>
</div>
}
footerElements={
<>
<Button variant='ghosted-white' className={style.apart} onClick={onStartEmpty}>
Start from an empty automation
</Button>
<Button onClick={onClose}>Cancel</Button>
</>
}
/>
);
}
interface RecipeSetupProps {
recipe: AutomationRecipe;
onClose: () => void;
onBack: () => void;
onCreated: (automation: Automation) => void;
}
function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
const [values, setValues] = useState<RecipeValues>(() => defaultValues(recipe));
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const automation = recipe.build(values);
const validationErrors = validateRecipeValues(recipe, values);
const isComplete = Object.keys(validationErrors).length === 0;
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
const handleCreate = async () => {
setError(null);
setIsCreating(true);
try {
const created = await addAutomation(
automation,
recipe.triggers.map((cycle) => ({
title: makeTriggerTitle(automation.title, cycle),
trigger: cycle,
})),
);
onCreated(created);
} catch (error) {
setError(maybeAxiosError(error));
} finally {
setIsCreating(false);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title={recipe.title}
bodyElements={
<div className={style.setup}>
<p className={style.setupDescription}>{recipe.description}</p>
<dl className={style.summary}>
<dt>Runs on</dt>
<dd>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
</dd>
<dt>Sends</dt>
<dd>
{summariseOutputs(automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))}
</dd>
</dl>
{recipe.params.length > 0 && (
<div className={style.fields}>
{recipe.params.map((param) => (
<label key={param.name} className={cx([style.field, param.wide && style.wide])}>
{param.label}
{param.type === 'choice' ? (
<Select
value={values[param.name]}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(param.name, value);
}}
options={param.options ?? []}
aria-label={param.label}
fluid
/>
) : (
<Input
type={param.type === 'number' ? 'number' : 'text'}
value={values[param.name]}
onChange={(event) => setValue(param.name, event.target.value)}
aria-invalid={Boolean(validationErrors[param.name])}
fluid
/>
)}
{param.hint && <span className={style.hint}>{param.hint}</span>}
<Panel.Error>{validationErrors[param.name]}</Panel.Error>
</label>
))}
</div>
)}
<Panel.Description>
Created as a normal automation, which you can edit or delete like any other.
</Panel.Description>
</div>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button variant='ghosted-white' className={style.apart} onClick={onBack} disabled={isCreating}>
<IoArrowBack /> All recipes
</Button>
<Button onClick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button variant='primary' onClick={handleCreate} loading={isCreating} disabled={!isComplete}>
Create automation <IoAdd />
</Button>
</>
}
/>
);
}
@@ -1,10 +1,11 @@
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types'; import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
import { PropsWithChildren, useState } from 'react'; import { useState } from 'react';
import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'; 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 type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput'; import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps { interface OntimeActionFormProps {
index: number; index: number;
register: UseFormRegister<AutomationDTO>; register: UseFormRegister<AutomationDTO>;
rowErrors?: { rowErrors?: OutputErrors;
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action']; value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>; watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>; setValue: UseFormSetValue<AutomationDTO>;
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
setValue, setValue,
rowErrors, rowErrors,
value, value,
children,
watch, watch,
}: PropsWithChildren<OntimeActionFormProps>) { }: OntimeActionFormProps) {
const [selectedAction, setSelectedAction] = useState<string>(value); const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => { const handleSetAction = (value: OntimeActionKey) => {
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
}; };
return ( return (
<div className={style.actionSection}> <>
<label> <label>
Action Action
<Select <Select
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && ( {selectedAction === 'message-set' && (
<> <>
<label> <label className={style.spanFull}>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -127,7 +121,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <>
<label> <label className={style.spanFull}>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
</label> </label>
</> </>
)} )}
</>
<div className={style.test}>{children}</div>
</div>
); );
} }
@@ -0,0 +1,63 @@
import type { AutomationDTO, OSCOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface OscOutputFormProps {
index: number;
output: OSCOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
return (
<>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
</>
);
}
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react';
import { IoCheckmark, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AutomationForm.module.scss';
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
interface OutputCardProps {
label: string;
kindClass?: string;
summary?: string;
testState?: TestState;
onTest: () => void;
onDelete: () => void;
children: ReactNode;
}
/**
* Shared chrome for every output kind: the type tag and the actions live in the header,
* so they stop competing with the form fields for grid columns
*/
export default function OutputCard({
label,
kindClass,
summary,
testState,
onTest,
onDelete,
children,
}: OutputCardProps) {
return (
<div className={style.card}>
<div className={style.cardHeader}>
<Tag className={kindClass}>{label}</Tag>
<span className={style.cardSummary}>{summary}</span>
{testState?.status === 'ok' && (
<span className={style.testOk}>
<IoCheckmark />
{testState.message}
</span>
)}
<Button variant='ghosted-white' onClick={onTest} loading={testState?.status === 'sending'}>
Test
</Button>
<IconButton aria-label='Delete output' variant='ghosted-destructive' onClick={onDelete}>
<IoTrash />
</IconButton>
</div>
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
<div className={style.cardBody}>{children}</div>
</div>
);
}
@@ -1,13 +1,13 @@
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm'; import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
@@ -20,13 +20,13 @@ type FormState = {
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean; isLoading: boolean;
} }
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) { export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const { setLocation } = useAppSettingsNavigation();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
@@ -50,6 +50,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
}; };
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]); const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
const orphans = useMemo(
() => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length,
[triggers, automations],
);
// there is no point letting user creating a trigger if there are no automations // there is no point letting user creating a trigger if there are no automations
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
@@ -66,22 +70,28 @@ export default function TriggersList({ triggers, automations, enabledAutomations
/> />
)} )}
<Panel.SubHeader> <Panel.SubHeader>
Manage triggers Global triggers
<Button disabled={!canAdd} onClick={openNewForm}> <Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
{enabledAutomations === false && ( <Panel.Description>
<Info> Triggers are managed from the automation itself. This list is for naming them, or for pointing several
Automations are disabled. You can still manage triggers here, but they will not run until enabled. differently named triggers at the same automation.
</Info> </Panel.Description>
)}
{duplicates && ( {duplicates && (
<Panel.Error> <Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance You have created multiple links between the same trigger and automation. Duplicate combinations will only
issues. fire once per lifecycle event.
</Panel.Error>
)}
{orphans > 0 && (
<Panel.Error>
{orphans === 1
? '1 trigger points at an automation that no longer exists and will never run.'
: `${orphans} triggers point at automations that no longer exist and will never run.`}
</Panel.Error> </Panel.Error>
)} )}
<Panel.Table> <Panel.Table>
@@ -99,31 +109,32 @@ export default function TriggersList({ triggers, automations, enabledAutomations
title='No triggers yet' title='No triggers yet'
description={ description={
canAdd canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.' ? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.'
: 'Create an automation first, then add a trigger to decide when it should run.' : 'Create an automation first, then pick the lifecycles it should run on.'
} }
action={ action={
canAdd && ( canAdd ? (
<Button variant='primary' onClick={openNewForm}> <Button variant='primary' onClick={openNewForm}>
Create trigger <IoAdd /> Create trigger <IoAdd />
</Button> </Button>
) : (
<Button variant='primary' onClick={() => setLocation('automation__automations')}>
Go to automations
</Button>
) )
} }
/> />
)} )}
{triggers.map((trigger, index) => { {triggers.map((trigger, index) => (
return ( <TriggersListItem
<Fragment key={trigger.id}> key={trigger.id}
<TriggersListItem automations={automations}
automations={automations} trigger={trigger}
trigger={trigger} duplicate={duplicates?.includes(index)}
duplicate={duplicates?.includes(index)} handleEdit={() => openEditForm(trigger)}
handleEdit={() => openEditForm(trigger)} handleDelete={() => handleDelete(trigger.id)}
handleDelete={() => handleDelete(trigger.id)} />
/> ))}
</Fragment>
);
})}
{deleteError && ( {deleteError && (
<tr> <tr>
<td colSpan={5}> <td colSpan={5}>
@@ -16,6 +16,7 @@ interface TriggersListItemProps {
export default function TriggersListItem(props: TriggersListItemProps) { export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props; const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const automation = automations[trigger.automationId];
return ( return (
<tr data-warn={duplicate}> <tr data-warn={duplicate}>
@@ -31,7 +32,8 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td> </td>
<td> <td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag> {/* a trigger can outlive the automation it points at, say after a partial project import */}
{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
@@ -0,0 +1,49 @@
import { automationRecipes, defaultValues, getAvailableRecipes, validateRecipeValues } from '../automationRecipes';
function buildOutputs(id: string, values: Record<string, string>): string {
const recipe = automationRecipes.find((candidate) => candidate.id === id);
return JSON.stringify(recipe?.build(values).outputs);
}
describe('automation recipes', () => {
it('builds a valid default automation for every recipe', () => {
for (const recipe of automationRecipes) {
expect(recipe.triggers).not.toEqual([]);
expect(recipe.build(defaultValues(recipe))).toMatchObject({
title: expect.any(String),
filterRule: 'all',
filters: [],
});
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
}
});
it('hides recipes that require the local network in Ontime Cloud', () => {
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
'ontime-aux-timer',
'ontime-aux-stop',
'ontime-warn-stage',
'ontime-clear-message',
'ontime-secondary-message',
'webhook-event-title',
]);
});
it('accepts a single-label hostname for OSC', () => {
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
expect(qlab && validateRecipeValues(qlab, { ip: 'qlab', port: '53000' })).toEqual({});
});
it('preserves an existing webhook query and URL-encodes its event title', () => {
expect(buildOutputs('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
'/now?source=ontime&title={{url:eventNow.title}}',
);
});
it('removes a pasted trailing slash before adding a Companion path', () => {
expect(
buildOutputs('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
});
});
@@ -1,6 +1,6 @@
import { TimerLifeCycle, Trigger } from 'ontime-types'; import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils'; import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
describe('checkDuplicates', () => { describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => { it('should return undefined if there are no duplicates', () => {
@@ -22,3 +22,43 @@ describe('checkDuplicates', () => {
expect(checkDuplicates(triggers)).toStrictEqual([2]); expect(checkDuplicates(triggers)).toStrictEqual([2]);
}); });
}); });
describe('groupTriggersByAutomation', () => {
it('returns an empty object when there are no triggers', () => {
expect(groupTriggersByAutomation([])).toEqual({});
});
it('collects the lifecycles each automation is bound to', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' },
];
expect(groupTriggersByAutomation(triggers)).toEqual({
a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
b: [TimerLifeCycle.onLoad],
});
});
it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' },
];
expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] });
});
});
describe('operators', () => {
it('does not offer not_contains, which the server validation rejects', () => {
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
});
});
describe('cycles', () => {
it('uses the shared user facing labels', () => {
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
});
});
@@ -0,0 +1,322 @@
import type { AutomationDTO, AutomationOutput, TimerLifeCycle } from 'ontime-types';
import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
ontime: 'Ontime automations',
playback: 'Playback and cue systems',
video: 'Video and streaming',
messaging: 'Webhooks and messaging',
};
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
export type RecipeParam = {
name: string;
label: string;
hint?: string;
type?: 'text' | 'number' | 'choice';
options?: { value: string; label: string }[];
wide?: boolean;
defaultValue: string;
validation?: { kind: 'host' } | { kind: 'url' } | { kind: 'integer'; min: number; max?: number };
};
export type RecipeValues = Record<string, string>;
export type AutomationRecipe = {
id: string;
title: string;
description: string;
category: RecipeCategory;
localOnly?: boolean;
keywords?: string[];
params: RecipeParam[];
triggers: TimerLifeCycle[];
build: (values: RecipeValues) => AutomationDTO;
};
const auxTimers = [
{ value: '1', label: 'Aux timer 1' },
{ value: '2', label: 'Aux timer 2' },
{ value: '3', label: 'Aux timer 3' },
];
type AuxNumber = '1' | '2' | '3';
/**
* Action keys are a union the compiler checks against the automation schema, so the aux
* number is resolved through maps rather than string interpolation. Anything unexpected
* falls back to the first timer instead of building an action the server would reject.
*/
function toAux(value: string): AuxNumber {
return value === '2' || value === '3' ? value : '1';
}
const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
function origin(value: string): string {
return value.trim().replace(/\/+$/, '');
}
function withQuery(url: string, query: string): string {
const trimmed = url.trim();
return trimmed.includes('?') ? `${trimmed}&${query}` : `${trimmed}?${query}`;
}
function buildUnfilteredAutomation(title: string, outputs: AutomationOutput[]): AutomationDTO {
return { title, filterRule: 'all', filters: [], outputs };
}
export const automationRecipes: AutomationRecipe[] = [
{
id: 'ontime-aux-timer',
title: 'Run an aux timer with the event',
description: 'Sets an aux timer and starts it whenever an event starts.',
category: 'ontime',
keywords: ['countdown', 'stage timer', 'speaker'],
params: [
{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' },
{ name: 'duration', label: 'Duration', hint: 'hh:mm:ss', defaultValue: '00:05:00' },
],
triggers: [Cycle.onStart],
build: ({ aux, duration }) =>
buildUnfilteredAutomation(`Run Aux Timer ${toAux(aux)} with the event`, [
{ type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
{ type: 'ontime', action: auxStart[toAux(aux)] },
]),
},
{
id: 'ontime-aux-stop',
title: 'Stop the aux timer when the event ends',
description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
category: 'ontime',
keywords: ['countdown', 'stage timer', 'reset'],
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
triggers: [Cycle.onFinish],
build: ({ aux }) =>
buildUnfilteredAutomation(`Stop Aux Timer ${toAux(aux)} on finish`, [
{ type: 'ontime', action: auxStop[toAux(aux)] },
]),
},
{
id: 'ontime-warn-stage',
title: 'Warn the stage when time runs low',
description: 'Shows a message on the stage timer as the running event enters its danger window.',
category: 'ontime',
keywords: ['message', 'danger', 'wrap up', 'presenter'],
params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }],
triggers: [Cycle.onDanger],
build: ({ message }) =>
buildUnfilteredAutomation('Warn the stage at danger', [
{ type: 'ontime', action: 'message-set', text: message, visible: true },
]),
},
{
id: 'ontime-clear-message',
title: 'Hide the stage message on finish',
description: 'Clears the stage message once the event finishes. Pairs with the warning above.',
category: 'ontime',
keywords: ['message', 'clear', 'presenter'],
params: [],
triggers: [Cycle.onFinish],
build: () =>
buildUnfilteredAutomation('Hide the stage message on finish', [
{ type: 'ontime', action: 'message-set', text: '', visible: false },
]),
},
{
id: 'ontime-secondary-message',
title: 'Show an aux timer beside the stage message',
description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
category: 'ontime',
keywords: ['message', 'secondary', 'stage', 'countdown'],
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
triggers: [Cycle.onLoad],
build: ({ aux }) =>
buildUnfilteredAutomation(`Show Aux Timer ${toAux(aux)} as the secondary message`, [
{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] },
]),
},
{
id: 'qlab-go',
title: 'QLab — fire the matching cue',
description: "Starts the QLab cue whose number matches the Ontime event's cue.",
category: 'playback',
localOnly: true,
keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'],
params: [
{
name: 'ip',
label: 'QLab computer',
hint: 'IP address of the machine running QLab',
wide: true,
defaultValue: '127.0.0.1',
validation: { kind: 'host' },
},
{
name: 'port',
label: 'OSC port',
type: 'number',
hint: "QLab's default is 53000",
defaultValue: '53000',
validation: { kind: 'integer', min: 1, max: 65535 },
},
],
triggers: [Cycle.onStart],
build: ({ ip, port }) =>
buildUnfilteredAutomation('QLab GO on event start', [
{
type: 'osc',
targetIP: ip.trim(),
targetPort: Number(port),
address: '/cue/{{eventNow.cue}}/start',
args: '',
},
]),
},
{
id: 'companion-press',
title: 'Companion — press a button',
description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.',
category: 'playback',
localOnly: true,
keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'],
params: [
{
name: 'host',
label: 'Companion address',
hint: 'Where the Companion HTTP API is listening',
wide: true,
defaultValue: 'http://127.0.0.1:8888',
validation: { kind: 'url' },
},
{ name: 'page', label: 'Page', type: 'number', defaultValue: '1', validation: { kind: 'integer', min: 1 } },
{ name: 'row', label: 'Row', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
{ name: 'column', label: 'Column', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
],
triggers: [Cycle.onStart],
build: ({ host, page, row, column }) =>
buildUnfilteredAutomation('Companion button press', [
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
]),
},
{
id: 'vmix-overlay-warning',
title: 'vMix — show an overlay on warning',
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
category: 'video',
localOnly: true,
keywords: ['streaming', 'http', 'lower third', 'graphics'],
params: [
{
name: 'host',
label: 'vMix address',
hint: 'The vMix web controller',
wide: true,
defaultValue: 'http://127.0.0.1:8088',
validation: { kind: 'url' },
},
{
name: 'overlay',
label: 'Overlay number',
type: 'number',
defaultValue: '1',
validation: { kind: 'integer', min: 1, max: 4 },
},
],
triggers: [Cycle.onWarning],
build: ({ host, overlay }) =>
buildUnfilteredAutomation('vMix overlay on warning', [
{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` },
]),
},
{
id: 'webhook-event-title',
title: 'Webhook — post the running event',
description: 'Calls any URL with the running event title, as a template string you can edit afterwards.',
category: 'messaging',
keywords: ['http', 'rest', 'api', 'integration', 'slack'],
params: [
{
name: 'url',
label: 'URL',
hint: 'The event title is added as a title parameter',
wide: true,
defaultValue: 'http://127.0.0.1:3000/now',
validation: { kind: 'url' },
},
],
triggers: [Cycle.onStart],
build: ({ url }) =>
buildUnfilteredAutomation('Webhook with the current event', [
{ type: 'http', url: withQuery(url, 'title={{url:eventNow.title}}') },
]),
},
];
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
}
export function getAvailableRecipes(isCloud: boolean): AutomationRecipe[] {
return isCloud ? automationRecipes.filter((recipe) => !recipe.localOnly) : automationRecipes;
}
export function validateRecipeValues(recipe: AutomationRecipe, values: RecipeValues): Record<string, string> {
const errors: Record<string, string> = {};
for (const param of recipe.params) {
const value = values[param.name]?.trim() ?? '';
if (!value) {
errors[param.name] = 'Required field';
continue;
}
if (param.validation?.kind === 'url' && !isHttpUrl(value)) {
errors[param.name] = 'Enter a URL starting with http:// or https://';
} else if (param.validation?.kind === 'host' && !isHost(value)) {
errors[param.name] = 'Enter an IP address or hostname';
} else if (param.validation?.kind === 'integer') {
const number = Number(value);
const { min, max } = param.validation;
if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
errors[param.name] =
max === undefined ? `Enter a whole number of ${min} or more` : `Enter a whole number from ${min} to ${max}`;
}
}
}
return errors;
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname);
} catch {
return false;
}
}
function isHost(value: string): boolean {
if (value === 'localhost') {
return true;
}
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
const hostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
if (ipv4.test(value) || hostname.test(value)) {
return true;
}
try {
return value.includes(':') && Boolean(new URL(`http://[${value}]`).hostname);
} catch {
return false;
}
}
@@ -1,21 +1,50 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types'; import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
/**
* Names a trigger created from an automation's lifecycle picker.
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
*/
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
return `${automationTitle}${getLifecycleLabel(cycle)}`;
}
/**
* Outputs are a union, so react-hook-form cannot resolve a field's error by name.
* Every output card knows which fields it registered, this just makes them reachable.
*/
export type OutputErrors = Partial<Record<string, { message?: string }>>;
type CycleLabel = { type CycleLabel = {
id: number;
label: string; label: string;
value: keyof typeof TimerLifeCycle; value: TimerLifeCycle;
}; };
export const cycles: CycleLabel[] = [ export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' }, { label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
{ id: 2, label: 'On Start', value: 'onStart' }, { label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
{ id: 3, label: 'On Pause', value: 'onPause' }, { label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
{ id: 4, label: 'On Stop', value: 'onStop' }, { label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
{ id: 5, label: 'Every second', value: 'onClock' }, { label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' }, { label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
{ id: 7, label: 'On Finish', value: 'onFinish' }, { label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
{ id: 8, label: 'On Warning', value: 'onWarning' }, { label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
{ id: 9, label: 'On Danger', value: 'onDanger' }, { label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
];
/**
* Filter operators offered in the automation form
* NOTE: not_contains is supported by the type and by the runtime, but the server
* validation list omits it, so an automation using it cannot be saved.
* It stays out of the UI until the server accepts it.
*/
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'does not equal' },
{ value: 'contains', label: 'contains' },
{ value: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
]; ];
/** /**
@@ -66,20 +95,38 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
* We warn the user if they have created multiple links between the same automation and a trigger * We warn the user if they have created multiple links between the same automation and a trigger
*/ */
export function checkDuplicates(triggers: Trigger[]) { export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {}; const seen = new Set<string>();
const duplicates = []; const duplicates: number[] = [];
for (let i = 0; i < triggers.length; i++) { for (const [index, trigger] of triggers.entries()) {
const trigger = triggers[i]; const key = `${trigger.trigger}:${trigger.automationId}`;
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
if (triggersMap[trigger.trigger].includes(trigger.automationId)) { if (seen.has(key)) {
duplicates.push(i); duplicates.push(index);
} else { } else {
triggersMap[trigger.trigger].push(trigger.automationId); seen.add(key);
} }
} }
return duplicates.length > 0 ? duplicates : undefined; return duplicates.length > 0 ? duplicates : undefined;
} }
/**
* Groups the lifecycles each automation is bound to
* Used to show when an automation runs, and to highlight the ones that never will
*/
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
const grouped: Record<string, TimerLifeCycle[]> = {};
for (const trigger of triggers) {
if (!Object.hasOwn(grouped, trigger.automationId)) {
grouped[trigger.automationId] = [];
}
// the runtime fires an automation once per lifecycle, duplicates would be noise here
if (!grouped[trigger.automationId].includes(trigger.trigger)) {
grouped[trigger.automationId].push(trigger.trigger);
}
}
return grouped;
}
@@ -1,7 +0,0 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,103 +1,72 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5'; import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report'; import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import { useLoadedRundown } from '../../../../common/hooks-query/useLoadedRundown';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils'; import ReportShowSummary from './composite/ReportShowSummary';
import ReportTable from './composite/ReportTable';
import style from './ReportSettings.module.scss'; import { getCombinedReport, getGroupReports, getRunSummary, makeReportCSV } from './reportSettings.utils';
export default function ReportSettings() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { report } = useReport();
const { data } = useLoadedRundown(); const { eventReports, rundown, show } = report;
const { combinedReport, groups, summary } = useMemo(() => {
const entries = rundown?.entries ?? {};
return {
combinedReport: rundown ? getCombinedReport(eventReports, entries, rundown.flatOrder) : [],
groups: rundown ? getGroupReports(eventReports, entries, rundown.order) : [],
summary: getRunSummary(eventReports, entries, rundown?.flatOrder ?? []),
};
}, [eventReports, rundown]);
const hasReport = rundown !== null && Object.keys(eventReports).length > 0;
const downloadCSV = () => {
if (!hasReport) return;
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport); const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;'); const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv'); downloadBlob(blob, 'ontime-report.csv');
}; };
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader> <Panel.SubHeader>
Report
<Panel.InlineElements>
<Button onClick={downloadCSV} disabled={!hasReport}>
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={deleteAllReport} disabled={!hasReport}>
<IoTrashBin />
Clear Report
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => { {!hasReport ? (
const start = (() => { <Panel.Section>
if (entry.actualStart === null) return null; <Panel.EmptyState
if (entry.actualStart <= entry.scheduledStart) return 'under'; title='No report yet'
return 'over'; description='Start an event to record actual timings against the schedule.'
})(); />
const end = (() => { </Panel.Section>
if (entry.actualEnd === null) return null; ) : (
if (entry.actualEnd <= entry.scheduledEnd) return 'under'; <>
return 'over'; <Panel.Section>
})(); <ReportShowSummary rundownTitle={rundown.title} show={show} summary={summary} />
return ( </Panel.Section>
<tr key={entry.id}> <Panel.Section>
<th>{entry.index}</th> <ReportTable rows={combinedReport} groups={groups} />
<th>{entry.cue}</th> </Panel.Section>
<th>{entry.title}</th> </>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th> )}
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -0,0 +1,156 @@
import type { OntimeEventReport, OntimeReport } from 'ontime-types';
import {
createDelay,
createEvent,
createGroup,
dayInMs,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
} from 'ontime-utils';
import {
formatOffset,
getCombinedReport,
getGroupReports,
getRunSummary,
getShowOffsets,
makeReportCSV,
} from '../reportSettings.utils';
function makeEvent(id: string, patch = {}) {
const event = createEvent({ id, title: id, ...patch });
if (!event) throw new Error('Failed to create test event');
return event;
}
function makeReport(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
return {
startedAt: 5 * MILLIS_PER_MINUTE,
startedAtDay: 1,
endedAt: 15 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
...patch,
};
}
describe('getCombinedReport()', () => {
it('uses the captured schedule and absolute day when calculating offsets', () => {
const entry = makeEvent('a', { timeStart: 0, duration: 99 * MILLIS_PER_MINUTE });
const rows = getCombinedReport({ a: makeReport() }, { a: entry }, ['a']);
expect(rows[0]).toMatchObject({
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledEnd: dayInMs + 5 * MILLIS_PER_MINUTE,
startOffset: 10 * MILLIS_PER_MINUTE,
endOffset: 10 * MILLIS_PER_MINUTE,
});
});
it('includes unplayed events but excludes skipped and non-event entries', () => {
const ran = makeEvent('ran');
const unplayed = makeEvent('unplayed', { timeStart: 20 * MILLIS_PER_MINUTE });
const skipped = makeEvent('skipped', { skip: true });
const delay = createDelay({ id: 'delay' });
const report: OntimeReport = { ran: makeReport({ scheduledStart: 0, scheduledDay: 0 }) };
const rows = getCombinedReport(report, { ran, unplayed, skipped, delay }, ['ran', 'unplayed', 'skipped', 'delay']);
expect(rows.map(({ id }) => id)).toEqual(['ran', 'unplayed']);
expect(rows[1]).toMatchObject({ scheduledStart: unplayed.timeStart, actualStart: null, actualEnd: null });
});
});
describe('report calculations', () => {
it('keeps finishing time separate from running time', () => {
const offsets = getShowOffsets({
plannedStart: 19 * MILLIS_PER_HOUR,
plannedEnd: 21 * MILLIS_PER_HOUR,
plannedDuration: 2 * MILLIS_PER_HOUR,
actualStart: 19 * MILLIS_PER_HOUR - 10 * MILLIS_PER_MINUTE,
actualEnd: 21 * MILLIS_PER_HOUR - 6 * MILLIS_PER_MINUTE,
actualDuration: 2 * MILLIS_PER_HOUR + 4 * MILLIS_PER_MINUTE,
});
expect(offsets).toMatchObject({
startOffset: -10 * MILLIS_PER_MINUTE,
endOffset: -6 * MILLIS_PER_MINUTE,
durationOffset: 4 * MILLIS_PER_MINUTE,
});
});
it('measures completed groups against their target', () => {
const group = createGroup({ id: 'group', entries: ['a', 'b'], targetDuration: 30 * MILLIS_PER_MINUTE });
const entries = {
group,
a: makeEvent('a', { parent: group.id, duration: 10 * MILLIS_PER_MINUTE }),
b: makeEvent('b', { parent: group.id, duration: 10 * MILLIS_PER_MINUTE }),
};
const report: OntimeReport = {
a: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 10 * MILLIS_PER_MINUTE, endedAtDay: 0 }),
b: makeReport({
startedAt: 15 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 25 * MILLIS_PER_MINUTE,
endedAtDay: 0,
}),
};
expect(getGroupReports(report, entries, [group.id])[0]).toMatchObject({
elapsed: 25 * MILLIS_PER_MINUTE,
variance: -5 * MILLIS_PER_MINUTE,
eventsRun: 2,
eventsPlanned: 2,
});
expect(getGroupReports({ a: report.a }, entries, [group.id])[0].variance).toBeNull();
});
it('summarises completed events and excludes skipped events from the plan', () => {
const entries = { a: makeEvent('a'), b: makeEvent('b', { skip: true }) };
const report = {
a: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 15 * MILLIS_PER_MINUTE, endedAtDay: 0 }),
b: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 30 * MILLIS_PER_MINUTE, endedAtDay: 0 }),
};
expect(getRunSummary(report, entries, ['a', 'b'])).toEqual({ eventsRun: 2, eventsPlanned: 1 });
});
});
describe('report formatting', () => {
it.each([
[null, ''],
[MILLIS_PER_SECOND / 2, 'On time'],
[4 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND, '+4m12s'],
[-MILLIS_PER_MINUTE, '-1m'],
])('formats offset %s', (value, expected) => {
expect(formatOffset(value)).toBe(expected);
});
it('exports group context and leaves missing actual times empty', () => {
const csv = makeReportCSV([
{
id: 'a',
index: 1,
title: 'Welcome',
cue: '1',
parent: 'act1',
groupTitle: 'Act 1',
scheduledStart: 0,
scheduledEnd: 10 * MILLIS_PER_MINUTE,
actualStart: null,
startOffset: null,
actualEnd: null,
endOffset: null,
},
]);
const fields = csv.trim().split('\n')[1].split(',');
expect(csv).toContain('Group');
expect(fields[1]).toBe('Act 1');
expect(fields[5]).toBe('');
expect(fields[7]).toBe('');
});
});
@@ -0,0 +1,104 @@
// Panel.Table nests its own padding inside the section it sits in, so the
// summary takes the same inset to keep one left edge down the whole panel
.inset {
padding: 0 var(--panel-card-padding, 2rem);
}
.summary {
padding: 1.25rem;
background-color: $gray-1200;
border-radius: 3px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.title {
margin: 0;
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.body {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem 2.5rem;
}
.headline {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.headlineLabel,
.metricLabel {
color: $gray-300;
font-size: calc(1rem - 2px);
}
.headlineValue {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.unavailable {
max-width: 32rem;
color: $warning-orange;
font-size: calc(1rem - 2px);
line-height: 1.4;
}
.metrics {
display: grid;
grid-template-columns: auto auto auto;
align-items: baseline;
gap: 0.375rem 1.5rem;
margin: 0;
}
.metricValue,
.metricOffset {
margin: 0;
font-variant-numeric: tabular-nums;
}
.metricValue {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.planned,
.arrow {
color: $gray-300;
}
.actual {
color: $ui-white;
font-weight: 600;
}
.metricOffset {
justify-self: end;
font-weight: 600;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.none {
color: $gray-300;
}
@@ -0,0 +1,118 @@
import type { MaybeNumber, ShowReport } from 'ontime-types';
import { cx, enDash } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import { formatOffset, getShowOffsets, offsetTone } from '../reportSettings.utils';
import type { RunSummary } from '../reportSettings.utils';
import style from './ReportShowSummary.module.scss';
interface ReportShowSummaryProps {
rundownTitle: string;
show: ShowReport;
summary: RunSummary;
}
/**
* Leads the report with whether the show ran to the length it was planned for.
*
* Running time is the headline rather than finishing time because it is the
* part the team controls and the part that carries into the next run of the
* same rundown. Finishing time is the other question a report is asked, and
* the two can point opposite ways, so it stays beside it as its own row
* rather than being folded into a single figure.
*/
export default function ReportShowSummary({ rundownTitle, show, summary }: ReportShowSummaryProps) {
const offsets = getShowOffsets(show);
/**
* A show that stopped early has no meaningful end: its last event is simply
* where it got to. Measuring that against the plan would report a show which
* never finished as having come in comfortably short.
*/
const didReachEnd = summary.eventsRun > 0 && summary.eventsRun === summary.eventsPlanned;
const hasPlan = offsets.startOffset !== null;
return (
<div className={style.inset}>
<section className={style.summary} aria-labelledby='report-summary-title'>
<h4 id='report-summary-title' className={style.title}>
{rundownTitle || 'Untitled rundown'}
</h4>
<div className={style.body}>
<div className={style.headline}>
<span className={style.headlineLabel}>{didReachEnd ? 'Show duration' : 'Show incomplete'}</span>
{didReachEnd && offsets.durationOffset !== null ? (
<span className={cx([style.headlineValue, style[offsetTone(offsets.durationOffset)]])}>
{formatOffset(offsets.durationOffset)}
</span>
) : (
<span className={style.unavailable}>The show did not reach the end of the rundown.</span>
)}
</div>
<dl className={style.metrics}>
{hasPlan && (
<Metric
label='Started'
planned={formatMaybeTime(show.plannedStart)}
actual={formatMaybeTime(show.actualStart)}
offset={offsets.startOffset}
/>
)}
{hasPlan && didReachEnd && (
<Metric
label='Ended'
planned={formatMaybeTime(show.plannedEnd)}
actual={formatMaybeTime(show.actualEnd)}
offset={offsets.endOffset}
/>
)}
{didReachEnd && (
<Metric
label='Duration'
planned={formatMaybeDuration(show.plannedDuration)}
actual={formatMaybeDuration(show.actualDuration)}
/>
)}
</dl>
</div>
</section>
</div>
);
}
function Metric({
label,
planned,
actual,
offset,
}: {
label: string;
planned: string;
actual: string;
offset?: MaybeNumber;
}) {
return (
<>
<dt className={style.metricLabel}>{label}</dt>
<dd className={style.metricValue}>
<span className={style.planned}>{planned}</span>
<span className={style.arrow}></span>
<span className={style.actual}>{actual}</span>
</dd>
<dd className={cx([style.metricOffset, offset !== undefined && style[offsetTone(offset)]])}>
{offset === undefined ? '' : formatOffset(offset)}
</dd>
</>
);
}
function formatMaybeTime(value: MaybeNumber): string {
return value === null ? enDash : formatTime(value);
}
function formatMaybeDuration(value: MaybeNumber): string {
return value === null ? enDash : formatDuration(value, false);
}
@@ -0,0 +1,144 @@
$rail: var(--user-bg, #{$gray-500});
$event-wash: var(--event-bg, transparent);
// event rows carry their values in td, not th: the panel's th styling is meant
// for column headings and renders whatever it holds small, bold and upper case
td.over {
color: $playback-over;
}
td.under {
color: $playback-under;
}
.eventRow td {
background-color: color-mix(in srgb, #{$gray-1300} 96%, #{$event-wash} 4%);
}
.groupedRow td:first-child {
box-shadow: inset 2px 0 $rail;
// clear the rail rather than sitting against it
padding-left: 0.75rem;
}
.groupRow > * {
background-color: color-mix(in srgb, #{$gray-1300} 88%, #{$rail} 12%);
vertical-align: top;
}
.groupSpacer {
height: 0.75rem;
background: transparent !important;
td {
height: 0.75rem;
padding: 0;
background: transparent !important;
}
}
th.groupSummary {
padding: 0.5rem 1rem;
box-shadow: inset 4px 0 $rail;
text-align: left;
text-transform: none;
letter-spacing: normal;
> * {
text-transform: none;
}
}
.groupTitle,
.groupLabel,
.groupValues {
display: block;
}
.groupTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
text-transform: none;
}
.groupBody {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem 2.5rem;
margin-top: 0.75rem;
}
.groupHeadline {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.groupLabel,
.groupMetrics dt {
color: $gray-400;
font-size: calc(1rem - 3px);
font-weight: 400;
}
.groupHeadlineValue {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.groupMetrics {
display: grid;
grid-template-columns: auto auto;
align-items: baseline;
gap: 0.375rem 1.5rem;
margin: 0;
font-variant-numeric: tabular-nums;
}
.groupMetrics dd {
margin: 0;
}
.groupValues {
display: flex;
gap: 0.5rem;
color: $gray-300;
b {
color: $ui-white;
font-weight: 600;
}
}
.unavailable {
color: $gray-400;
font-size: calc(1rem - 3px);
font-weight: 400;
}
.arrow {
color: $gray-500;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.none {
color: $ui-white;
}
.eventCue,
.eventIndex {
color: $gray-300;
}
@@ -0,0 +1,182 @@
import type { EntryId } from 'ontime-types';
import { useMemo } from 'react';
import { cx, enDash } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import { formatOffset, offsetTone } from '../reportSettings.utils';
import type { CombinedReport, GroupReport } from '../reportSettings.utils';
import style from './ReportTable.module.scss';
interface ReportTableProps {
rows: CombinedReport[];
groups: GroupReport[];
}
type ReportColourStyle = React.CSSProperties & Partial<Record<'--event-bg' | '--user-bg', string | undefined>>;
/**
* The report laid out the way the show was planned: blocks, then the events
* inside them. Each block carries how it ran against the budget set for it.
*/
export default function ReportTable({ rows, groups }: ReportTableProps) {
// groups are rendered where their first event appears, so the table follows
// the rundown rather than a separate ordering
const sections = useMemo(() => makeSections(rows, groups), [rows, groups]);
return (
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
{sections.map((section, index) => (
<tbody key={section.key}>
{section.group && index > 0 && <GroupSpacer />}
{section.group && <GroupRow group={section.group} />}
{section.rows.map((entry) => (
<EventRow
key={entry.id}
entry={entry}
groupColour={section.group?.colour}
grouped={section.group !== null}
/>
))}
</tbody>
))}
</Panel.Table>
);
}
function GroupSpacer() {
return (
<tr aria-hidden='true' className={style.groupSpacer}>
<td colSpan={7} />
</tr>
);
}
/**
* A group read the same way as the show above it: what it was measured
* against, what it actually did, and how much of it ran.
*/
function GroupRow({ group }: { group: GroupReport }) {
const hasTarget = group.targetDuration !== null;
const measuredAgainst = group.targetDuration ?? group.scheduledDuration;
const unavailableReason = group.eventsRun === 0 ? 'Did not run' : 'Still running';
return (
<tr className={style.groupRow} style={groupColourStyle(group.colour)}>
<th scope='rowgroup' colSpan={7} className={style.groupSummary}>
<span className={style.groupTitle}>{group.title || 'Untitled group'}</span>
<div className={style.groupBody}>
<div className={style.groupHeadline}>
<span className={style.groupLabel}>{hasTarget ? 'Against target' : 'Against schedule'}</span>
{group.variance === null ? (
<span className={style.unavailable}>{unavailableReason}</span>
) : (
<span className={cx([style.groupHeadlineValue, style[offsetTone(group.variance)]])}>
{formatOffset(group.variance)}
</span>
)}
</div>
<dl className={style.groupMetrics}>
<dt>{hasTarget ? 'Target' : 'Scheduled'}</dt>
<dd className={style.groupValues}>
<span>{formatDuration(measuredAgainst, false)}</span>
<span className={style.arrow}></span>
<b>{group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}</b>
</dd>
{group.actualStart !== null && group.actualEnd !== null && (
<>
<dt>Ran</dt>
<dd className={style.groupValues}>
<span>{formatTime(group.actualStart)}</span>
<span className={style.arrow}></span>
<b>{formatTime(group.actualEnd)}</b>
</dd>
</>
)}
</dl>
</div>
</th>
</tr>
);
}
function EventRow({ entry, groupColour, grouped }: { entry: CombinedReport; groupColour?: string; grouped: boolean }) {
const start = offsetTone(entry.startOffset);
const end = offsetTone(entry.endOffset);
return (
<tr className={cx([style.eventRow, grouped && style.groupedRow])} style={eventColours(entry.colour, groupColour)}>
<td className={style.eventIndex}>{entry.index}</td>
<td className={style.eventCue}>{entry.cue}</td>
<td>{entry.title}</td>
<td>{formatTime(entry.scheduledStart)}</td>
<td className={cx([style[start]])}>{formatTime(entry.actualStart)}</td>
<td>{formatTime(entry.scheduledEnd)}</td>
<td className={cx([style[end]])}>{formatTime(entry.actualEnd)}</td>
</tr>
);
}
/**
* Uses the cuesheet's custom property for group colour. Left unset when the
* group has none so the stylesheet can provide a neutral edge.
*/
function groupColourStyle(colour?: string): ReportColourStyle {
const style: ReportColourStyle = {};
if (colour) style['--user-bg'] = colour;
return style;
}
/** Keeps the event wash distinct from its parent group's identifying rail. */
function eventColours(eventColour: string, groupColour?: string): ReportColourStyle {
const style: ReportColourStyle = {};
if (eventColour) style['--event-bg'] = eventColour;
if (groupColour) style['--user-bg'] = groupColour;
return style;
}
type Section = {
key: string;
group: GroupReport | null;
rows: CombinedReport[];
};
/**
* Splits the rows into the blocks they belong to, keeping rundown order and
* leaving ungrouped events in their own run of rows.
*/
function makeSections(rows: CombinedReport[], groups: GroupReport[]): Section[] {
const byId = new Map<EntryId, GroupReport>(groups.map((group) => [group.id, group]));
const sections: Section[] = [];
let current: Section | null = null;
let currentParent: EntryId | null | undefined;
for (const row of rows) {
if (current === null || row.parent !== currentParent) {
currentParent = row.parent;
// index keeps the key unique even if a group were to appear twice
current = {
key: `${row.parent ?? 'ungrouped'}-${sections.length}`,
group: row.parent ? (byId.get(row.parent) ?? null) : null,
rows: [],
};
sections.push(current);
}
current.rows.push(row);
}
return sections;
}
@@ -1,89 +1,241 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; import type { EntryId, MaybeNumber, OntimeGroup, OntimeReport, RundownEntries, ShowReport } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time'; import { getEventVariance, getReportTimePosition } from '../../../../common/utils/report';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = { export type CombinedReport = {
id: EntryId; id: EntryId;
index: number; index: number;
title: string; title: string;
cue: string; cue: string;
colour: string;
/** the group this event belongs to, so the report can mirror the rundown */
parent: EntryId | null;
groupTitle: string;
scheduledStart: number; scheduledStart: number;
actualStart: MaybeNumber; actualStart: MaybeNumber;
startOffset: MaybeNumber;
scheduledEnd: number; scheduledEnd: number;
actualEnd: MaybeNumber; actualEnd: MaybeNumber;
endOffset: MaybeNumber;
};
type ShowOffsets = {
startOffset: MaybeNumber;
endOffset: MaybeNumber;
durationOffset: MaybeNumber;
};
export type GroupReport = {
id: EntryId;
title: string;
colour: string;
targetDuration: MaybeNumber;
scheduledDuration: number;
actualStart: MaybeNumber;
actualEnd: MaybeNumber;
elapsed: MaybeNumber;
variance: MaybeNumber;
eventsRun: number;
eventsPlanned: number;
};
export type RunSummary = {
eventsRun: number;
eventsPlanned: number;
}; };
/** /**
* Creates a combined report with the rundown data * Creates a combined report with the rundown data.
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/ */
export function getCombinedReport( export function getCombinedReport(
report: OntimeReport, report: OntimeReport,
rundown: RundownEntries, rundown: RundownEntries,
flatOrder: EntryId[], flatOrder: EntryId[],
): CombinedReport[] { ): CombinedReport[] {
if (Object.keys(report).length === 0) return []; if (Object.keys(report).length === 0 || flatOrder.length === 0) return [];
if (flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = []; const combinedReport: CombinedReport[] = [];
let index = 1; let index = 1;
for (let i = 0; i < flatOrder.length; i++) { for (const id of flatOrder) {
const id = flatOrder[i];
const entry = rundown[id]; const entry = rundown[id];
if (!entry || !isOntimeEvent(entry)) continue; // skipped events were never meant to run, listing them alongside events
// that did would also disagree with the summary, which excludes them
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
if (!(id in report)) { const parent = entry.parent;
combinedReport.push({ const group = parent ? rundown[parent] : undefined;
id: id, const reported = report[id];
index: index, const scheduledStart = reported?.scheduledStart ?? entry.timeStart;
title: entry.title, const scheduledDay = reported?.scheduledDay ?? entry.dayOffset;
cue: entry.cue, const scheduledStartPosition = getReportTimePosition(scheduledStart, scheduledDay);
scheduledStart: entry.timeStart, const actualStartPosition = reported ? getReportTimePosition(reported.startedAt, reported.startedAtDay) : null;
actualEnd: null, const actualEndPosition = reported ? getReportTimePosition(reported.endedAt, reported.endedAtDay) : null;
scheduledEnd: entry.timeEnd, const scheduledDuration = reported?.scheduledDuration ?? entry.duration;
actualStart: null,
});
}
if (id in report) { combinedReport.push({
combinedReport.push({ id,
id: id, index,
index: index, title: entry.title,
title: entry.title, cue: entry.cue,
cue: entry.cue, colour: entry.colour,
scheduledStart: entry.timeStart, parent,
actualEnd: report[id].endedAt, groupTitle: group && isOntimeGroup(group) ? group.title : '',
scheduledEnd: entry.timeEnd, // an event that ran is measured against the plan it ran on, one that
actualStart: report[id].startedAt, // did not has no snapshot and falls back to the rundown
}); scheduledStart,
} scheduledEnd: scheduledStart + scheduledDuration,
actualStart: reported?.startedAt ?? null,
startOffset: getOffset(actualStartPosition, scheduledStartPosition),
actualEnd: reported?.endedAt ?? null,
endOffset: getOffset(actualEndPosition, scheduledStartPosition + scheduledDuration),
});
index++; index++;
} }
return combinedReport; return combinedReport;
} }
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; function getOffset(actual: MaybeNumber, scheduled: number): MaybeNumber {
return actual === null ? null : actual - scheduled;
}
/** export function getShowOffsets(show: ShowReport): ShowOffsets {
* Transforms a CombinedReport into a CSV string const { plannedDuration, actualDuration } = show;
*/ return {
export function makeReportCSV(combinedReport: CombinedReport[]) { startOffset: getWallClockOffset(show.plannedStart, show.actualStart),
const csv: string[][] = []; endOffset: getWallClockOffset(show.plannedEnd, show.actualEnd),
csv.push(csvHeader); durationOffset: plannedDuration === null || actualDuration === null ? null : actualDuration - plannedDuration,
};
}
for (const entry of combinedReport) { function getWallClockOffset(planned: MaybeNumber, actual: MaybeNumber): MaybeNumber {
csv.push([ if (planned === null || actual === null) return null;
String(entry.index),
entry.title, const offset = actual - planned;
entry.cue, if (offset < -dayInMs / 2) return offset + dayInMs;
formatTime(entry.scheduledStart), if (offset > dayInMs / 2) return offset - dayInMs;
formatTime(entry.actualStart), return offset;
formatTime(entry.scheduledEnd), }
formatTime(entry.actualEnd),
]); export function getGroupReports(report: OntimeReport, entries: RundownEntries, order: EntryId[]): GroupReport[] {
const groups: GroupReport[] = [];
for (const id of order) {
const group = entries[id];
if (group && isOntimeGroup(group)) groups.push(getGroupReport(group, report, entries));
}
return groups;
}
function getGroupReport(group: OntimeGroup, report: OntimeReport, entries: RundownEntries): GroupReport {
let scheduledDuration = 0;
let eventsPlanned = 0;
let eventsRun = 0;
let firstStart = Number.POSITIVE_INFINITY;
let lastEnd = Number.NEGATIVE_INFINITY;
let actualStart: MaybeNumber = null;
let actualEnd: MaybeNumber = null;
for (const childId of group.entries) {
const child = entries[childId];
if (!child || !isOntimeEvent(child) || child.skip) continue;
eventsPlanned += 1;
const reported = report[childId];
scheduledDuration += reported?.scheduledDuration ?? child.duration;
const variance = getEventVariance(reported);
if (variance.actualDuration === null || !reported) continue;
eventsRun += 1;
const start = getReportTimePosition(reported.startedAt, reported.startedAtDay);
const end = getReportTimePosition(reported.endedAt, reported.endedAtDay);
if (start !== null && start < firstStart) {
firstStart = start;
actualStart = reported.startedAt;
}
if (end !== null && end > lastEnd) {
lastEnd = end;
actualEnd = reported.endedAt;
}
} }
return makeCSVFromArrayOfArrays(csv); const elapsed = actualStart === null || actualEnd === null ? null : lastEnd - firstStart;
const measuredAgainst = group.targetDuration ?? scheduledDuration;
const isComplete = eventsRun > 0 && eventsRun === eventsPlanned;
return {
id: group.id,
title: group.title,
colour: group.colour,
targetDuration: group.targetDuration,
scheduledDuration,
actualStart,
actualEnd,
elapsed,
variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst,
eventsRun,
eventsPlanned,
};
}
export function getRunSummary(report: OntimeReport, entries: RundownEntries, order: EntryId[]): RunSummary {
const eventsPlanned = order.filter((id) => {
const entry = entries[id];
return entry && isOntimeEvent(entry) && !entry.skip;
}).length;
const eventsRun = Object.values(report).filter((entry) => getEventVariance(entry).status !== 'not-run').length;
return { eventsRun, eventsPlanned };
}
/**
* Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that
* positive means behind schedule.
*/
export function formatOffset(value: MaybeNumber): string {
if (value === null) return enDash;
if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time';
return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`;
}
export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' {
if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none';
return value > 0 ? 'over' : 'under';
}
function formatCsvTime(value: MaybeNumber): string {
return value === null ? '' : formatTime(value);
}
const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string.
*
* Exported as one row per event with its group named, rather than with
* rollups baked in, so it stays the dataset a report is built from.
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv = combinedReport.map((entry) => [
String(entry.index),
entry.groupTitle,
entry.cue,
entry.title,
formatTime(entry.scheduledStart),
formatCsvTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatCsvTime(entry.actualEnd),
]);
return makeCSVFromArrayOfArrays([csvHeader, ...csv]);
} }
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; 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 { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { removeFileExtension, 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';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState(''); const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useLoadedRundown(); const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport(); const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && ( {!isOntimeCloud && (
<Panel.Section> <Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}> <Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown ontime Shutdown Ontime
</Button> </Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>} {!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section> </Panel.Section>
@@ -84,11 +84,25 @@ const staticOptions = [
{ {
id: 'automation__automations', id: 'automation__automations',
label: 'Manage automations', label: 'Manage automations',
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'], keywords: [
'osc',
'http',
'webhook',
'integration',
'api',
'output',
'action',
'recipe',
'example',
'preset',
'qlab',
'vmix',
'companion',
],
}, },
{ {
id: 'automation__triggers', id: 'automation__triggers',
label: 'Manage triggers', label: 'Global triggers',
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'], keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
}, },
], ],
@@ -18,6 +18,12 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+27 -14
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,7 +25,10 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50; /** Keeps the running event clear of the list edge when no group header is pinned above it */
const edgeOffset = 50;
/** How far the running event may drift from where we placed it before we stop following */
const followTolerance = 50;
export default function OperatorLoader() { export default function OperatorLoader() {
const { data, status } = useOperatorData(); const { data, status } = useOperatorData();
@@ -54,11 +57,20 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// The header height varies with the viewport, so measure it at scroll time.
const getTopOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Sit right under the pinned header, so it covers the previous event instead of half of it.
return header ? header.offsetHeight + 2 : edgeOffset;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: selectedOffset, getTopOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -82,15 +94,16 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Measure the drift from where an automated scroll would place the event.
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -186,9 +199,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
return ( return (
<Fragment key={entry.id}> <div className={style.groupSection} key={entry.id}>
<OperatorGroup <OperatorGroup
key={entry.id} ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title} title={entry.title}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} count={entry.entries.length}
@@ -239,7 +252,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</Fragment> </div>
); );
} }
return null; return null;
@@ -1,13 +1,20 @@
.group { .group {
width: 100%; width: 100%;
/* Padding is kept under the min-height so a single line fits without the list having to shrink the header,
while a taller title still grows the row. */
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.4rem 0.75rem; padding: 0.25rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500); border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -1,4 +1,4 @@
import { CSSProperties, memo } from 'react'; import { type CSSProperties, type Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -10,15 +10,16 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
const groupColour = colour || '#929292'; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour); const groupColours = getAccessibleColour(groupColour);
return ( return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}> <div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
<span className={style.title}>{title}</span> <span className={style.title}>{title}</span>
<span className={style.meta}> <span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span> <span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types'; import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata(); const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb'; } from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown'; import { useEntry } from '../../../common/hooks-query/useRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock'; import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import { import {
useCurrentGroupId, useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useLoadedEntry(currentGroupId) as OntimeGroup | null; const group = useEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() { function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useLoadedEntry(id) as OntimeEvent | null; const entry = useEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -1,12 +1,12 @@
import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../../common/hooks-query/useProjectData'; import useProjectData from '../../../common/hooks-query/useProjectData';
import { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss'; import style from './TitleOverview.module.scss';
export default function TitleOverview() { export default function TitleOverview() {
'use memo'; 'use memo';
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
const { data: rundownData } = useLoadedRundownAuxData(); const { data: rundownData } = useRundownAuxData();
const projectTitle = projectData.title.trim(); const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim(); const rundownTitle = rundownData.title.trim();
+1 -5
View File
@@ -67,11 +67,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id); const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext(); const entryActions = useEntryActionsContext();
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId); const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
// cursor // cursor
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
@@ -5,6 +5,9 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -36,25 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List, defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<ProtectRoute permission='editor'> <EntryActionsProvider actions={entryActions}>
<div <ProtectRoute permission='editor'>
className={cx([style.rundownExport, style.extracted])} <div
data-target='small-device' className={cx([style.rundownExport, style.extracted])}
data-testid='panel-rundown' data-target='small-device'
> data-testid='panel-rundown'
<FinderPlacement /> >
<ViewNavigationMenu suppressSettings /> <FinderPlacement />
<div className={style.rundown}> <ViewNavigationMenu suppressSettings />
<ErrorBoundary> <div className={style.rundown}>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} /> <ErrorBoundary>
<RundownContextMenu /> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
</ErrorBoundary> <RundownContextMenu />
</ErrorBoundary>
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
); );
} }
@@ -64,28 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table; viewMode === RundownViewMode.Table;
return ( return (
<ProtectRoute permission='editor'> <EntryActionsProvider actions={entryActions}>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <ProtectRoute permission='editor'>
<FinderPlacement /> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <FinderPlacement />
<div className={style.rundown}> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<Editor.Panel className={style.list}> <div className={style.rundown}>
<ErrorBoundary> <Editor.Panel className={style.list}>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<RundownEntryEditor /> {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div> </Editor.Panel>
)} {!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
); );
} }
@@ -97,6 +105,8 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
@@ -105,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />} {viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,22 +1,15 @@
import { Playback } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
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 { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
/** a rundown which is not loaded has no playback to follow */
const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null };
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const runtimeFeatureData = useRundownEditor(); const featureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty // avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -15,7 +15,7 @@
.triggerHeader { .triggerHeader {
display: grid; display: grid;
grid-template-columns: 8rem 1fr 2rem; grid-template-columns: 8rem 1fr auto 2rem;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
font-size: $aux-text-size; font-size: $aux-text-size;
@@ -25,7 +25,7 @@
.trigger { .trigger {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
display: grid; display: grid;
grid-template-columns: 8rem 1fr 2rem; grid-template-columns: 8rem 1fr auto 2rem;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.5rem; min-height: 2.5rem;
@@ -41,6 +41,12 @@
} }
} }
.outputTags {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.duplicateMessage { .duplicateMessage {
padding-left: 0.75rem; padding-left: 0.75rem;
font-size: $aux-text-size; font-size: $aux-text-size;
@@ -6,8 +6,11 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss'; import style from './EventEditorTriggers.module.scss';
@@ -27,7 +30,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
label: title, label: title,
})); }));
const hasAutomationOptions = allAutomationOptions.length > 0; const hasAutomationOptions = allAutomationOptions.length > 0;
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })); const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: getLifecycleLabel(cycle) }));
const duplicateIds = new Set<string>(); const duplicateIds = new Set<string>();
const seen = new Map<string, string>(); const seen = new Map<string, string>();
@@ -76,6 +79,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
<div className={style.triggerHeader}> <div className={style.triggerHeader}>
<span>Lifecycle</span> <span>Lifecycle</span>
<span>Automation</span> <span>Automation</span>
<span>Sends</span>
</div> </div>
{triggers.map((trigger) => { {triggers.map((trigger) => {
const isDuplicate = duplicateIds.has(trigger.id); const isDuplicate = duplicateIds.has(trigger.id);
@@ -103,6 +107,13 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
}} }}
options={automationOptions} options={automationOptions}
/> />
<div className={style.outputTags}>
{summariseOutputs(automationSettings.automations[trigger.automationId]?.outputs ?? []).map(
({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
),
)}
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}> <IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
@@ -2,7 +2,6 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils'; import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction'; import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection'; import { SelectionMode } from '../useEventSelection';
@@ -27,7 +26,6 @@ export function useRundownCommands({
selectEntry: applySelection, selectEntry: applySelection,
handleCollapseGroup, handleCollapseGroup,
}: UseRundownCommandsOptions) { }: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions; const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
@@ -45,17 +43,12 @@ export function useRundownCommands({
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => { (atId: EntryId | null, above = false) => {
// lazily get the value from the store // lazily get the value from the store
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState(); const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) { if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
// pasting into a different rundown needs the entry payload, the server clones within a rundown
if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) {
return;
}
let normalisedAtId = atId; let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId]; const elementToCopy = entries[entryCopyId];
@@ -72,7 +65,7 @@ export function useRundownCommands({
return; return;
} }
reorderEntry(entryCopyId, firstId, 'before') reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null, null)) .then(() => setEntryCopyId(null))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -81,7 +74,7 @@ export function useRundownCommands({
} }
const placement = above ? 'before' : 'after'; const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement) reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null, null)) .then(() => setEntryCopyId(null))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -92,7 +85,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined, before: above ? (normalisedAtId ?? undefined) : undefined,
}); });
}, },
[entries, flatOrder, clone, reorderEntry, rundownId], [entries, flatOrder, clone, reorderEntry],
); );
/** /**
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast} isPast={isPast}
isLoaded={loaded} isLoaded={loaded}
totalGap={totalGap} totalGap={totalGap}
duration={duration}
/> />
)} )}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}> <div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -6,6 +6,7 @@ import { IoCheckmarkCircle } from 'react-icons/io5';
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 { usePlayback } from '../../../../common/hooks/useSocket'; import { usePlayback } from '../../../../common/hooks/useSocket';
import { getEventVariance } from '../../../../common/utils/report';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
@@ -20,7 +21,6 @@ interface RundownEventChipProps {
isLoaded: boolean; isLoaded: boolean;
className: string; className: string;
totalGap: number; totalGap: number;
duration: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} }
@@ -33,7 +33,6 @@ export default function RundownEventChip({
className, className,
totalGap, totalGap,
id, id,
duration,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEventChipProps) { }: RundownEventChipProps) {
const playback = usePlayback(); const playback = usePlayback();
@@ -45,25 +44,20 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback); const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) { if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />; return <EventReport className={className} id={id} />;
} }
if (playbackActive) { return (
// we extracted the component to avoid unnecessary calculations and re-renders <Tooltip text='Expected time until start' render={<span />} className={className}>
return ( <EventUntil
<Tooltip text='Expected time until start' render={<span />} className={className}> timeStart={timeStart}
<EventUntil delay={delay}
timeStart={timeStart} dayOffset={dayOffset}
delay={delay} totalGap={totalGap}
dayOffset={dayOffset} isLinkedToLoaded={isLinkedToLoaded}
totalGap={totalGap} />
isLinkedToLoaded={isLinkedToLoaded} </Tooltip>
/> );
</Tooltip>
);
}
return null;
} }
interface EventUntilProps { interface EventUntilProps {
@@ -86,41 +80,30 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps { interface EventReportProps {
className: string; className: string;
id: string; id: string;
duration: number;
} }
function EventReport(props: EventReportProps) { function EventReport({ className, id }: EventReportProps) {
const { className, id, duration } = props;
const { data } = useReport(); const { data } = useReport();
const currentReport = data[id]; const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => { const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) { // Use the schedule recorded when the event ran so later rundown edits do
// not change its report.
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
return [null, 'none', '']; return [null, 'none', ''];
} }
const { startedAt, endedAt } = currentReport; if (variance.status === 'ontime') {
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'under', 'Event finished on time']; return ['ontime', 'under', 'Event finished on time'];
} }
const isOver = difference > 0; const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const fullTimeValue = millisToString(absDifference); const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${millisToString(absDifference)}`;
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip]; return [value, variance.status, tooltip];
}, [currentReport, duration]); }, [currentReport]);
if (!value) { if (!value) {
return null; return null;
@@ -1,6 +1,9 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
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 { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -12,6 +15,8 @@ function RundownTable() {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -27,8 +32,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return ( return (
<CuesheetDnd columns={columns} tableRoot='editor'> <EntryActionsProvider actions={actions}>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' /> <CuesheetDnd columns={columns} tableRoot='editor'>
</CuesheetDnd> <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
); );
} }
@@ -1,15 +1,155 @@
import { useStore } from 'zustand'; import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { useRundownScope } from '../../common/context/RundownScopeContext'; import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import type { EventSelectionStore } from '../../common/stores/eventSelectionStore'; import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
export { getSelectionMode } from '../../common/stores/eventSelectionStore'; export type SelectionMode = 'shift' | 'click' | 'ctrl';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
/** /**
* Selection and cursor for the rundown of the enclosing scope * Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*/ */
export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T { export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
const { selectionStore } = useRundownScope(); selectedEvents: new Set(),
return useStore(selectionStore, selector); anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
function getLoadedRundownData() {
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
} }
@@ -1,7 +1,6 @@
import { EntryId, MaybeString } from 'ontime-types'; import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups'; import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -11,8 +10,7 @@ type SelectAndRevealOptions = {
parent?: MaybeString; parent?: MaybeString;
}; };
export function useSelectAndRevealEntry() { export function useSelectAndRevealEntry(rundownId: string) {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId); const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents); const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -20,7 +20,7 @@ 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 } = useLoadedFlatRundown(); const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -12,7 +12,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown'; import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options'; import { useScheduleOptions } from './schedule.options';
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter], [filter],
); );
const { data: events } = useLoadedPartialRundown(filterCallback); const { data: events } = usePartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1); const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
+31 -3
View File
@@ -100,7 +100,20 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); padding-bottom: 95vh;
}
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
.sub-section {
display: flex;
flex-direction: column;
/* The select view renders the same cards in a flat list. */
.sub--group {
position: sticky;
top: 0;
z-index: 1;
}
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -196,9 +209,14 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background: /* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color); linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +248,16 @@ $item-height: 3.5rem;
} }
} }
/* Reserve the green fill for the running event. */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* Keep the armed state quieter than the live ring. */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';
@@ -22,6 +22,7 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the combined margins between the header and running event.
return header ? header.offsetHeight + 4 : 0;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: 0, getTopOffset: getStickyOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -75,15 +87,16 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -98,41 +111,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => { {sections.map((section) => {
// while a group is live, surface the running event's title as the secondary line const rows = section.group ? [section.group, ...section.events] : section.events;
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; // the running event anchors the scroll, the group header stays pinned above it
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div key={section.group?.id ?? rows[0].id} className='sub-section'>
key={event.id} {rows.map((event) => {
ref={isLive ? selectedRef : undefined} // while a group is live, surface the running event's title as the secondary line
className={cx([ const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
'sub', const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
isLive && 'sub--live', const isGroupedEvent = !event.isGroup && Boolean(event.parent);
isArmed && 'sub--armed', const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
event.isGroup && 'sub--group', // a subscribed group is live when any of its children is the selected/running event
isGroupedEvent && 'sub--in-group', const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
])} const isArmed = !isLive && activeEntryId === selectedEventId;
data-testid={event.cue} // only ever hand the ref to a single row, sharing it would null it out on the next commit
> const isAnchor = isLive && (anchorId === null || event.id === anchorId);
<div const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
className='sub__binder' const countdownEvent = extendEventData(
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }} event,
/> currentDay,
<ScheduleTime event={countdownEvent} showExpected={showExpected} /> actualStart,
<SubscriptionStatus event={countdownEvent} /> plannedStart,
<div className={cx(['sub__title', !displayTitle && 'subdued'])}> offset,
{event.isGroup && <span className='sub__eyebrow'>Group</span>} mode,
{displayTitle} reportData,
</div> );
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div> </div>
); );
})} })}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils'; import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/** /**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); expect(result?.liveEntry).toBeNull();
}); });
}); });
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,6 +252,42 @@ export function resolveSubscriptionTarget(
}; };
} }
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
sections.push({ group: target, events: [] });
continue;
}
const previousSection = sections.at(-1);
if (previousSection?.group?.id === target.parent) {
previousSection.events.push(target);
continue;
}
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata(); const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -3,31 +3,35 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton'; import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useRundownSelection } from '../../common/hooks/useRundownSelection'; import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper'; import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
'use memo'; 'use memo';
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
useRundownSelection('cuesheet'); const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<EditableRundownScopeProvider rundownId={scopedRundownId}> <EntryActionsProvider actions={actions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal /> <EntryEditModal rundown={source.rundown} />
<div className={styles.tableWrapper} data-testid='cuesheet'> <div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview> <CuesheetOverview>
{!isLocked && ( {!isLocked && (
@@ -38,12 +42,13 @@ export default function CuesheetPage() {
</CuesheetOverview> </CuesheetOverview>
<CuesheetProgress /> <CuesheetProgress />
<CuesheetTableWrapper <CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId} selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId} loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId} setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns} projectRundowns={projectRundowns}
/> />
</div> </div>
</EditableRundownScopeProvider> </EntryActionsProvider>
); );
} }
@@ -3,18 +3,19 @@ import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select'; import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection'; import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable'; import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps { interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString; selectedRundownId: MaybeString;
loadedRundownId: string; loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void; setSelectedRundownId: (rundownId: string) => void;
@@ -23,14 +24,15 @@ interface CuesheetTableWrapperProps {
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({ function CuesheetTableWrapper({
source,
selectedRundownId, selectedRundownId,
setSelectedRundownId, setSelectedRundownId,
loadedRundownId, loadedRundownId,
projectRundowns, projectRundowns,
}: CuesheetTableWrapperProps) { }: CuesheetTableWrapperProps) {
const preset = use(PresetContext); const preset = use(PresetContext);
const { isLoaded } = useRundownScope(); const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded }); const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const columns = useMemo( const columns = useMemo(
@@ -42,9 +44,11 @@ function CuesheetTableWrapper({
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
<CuesheetTable <CuesheetTable
columns={columns} columns={columns}
source={source}
cuesheetMode={cuesheetMode} cuesheetMode={cuesheetMode}
tableRoot='cuesheet' tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode} setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={ insertElement={
<> <>
<RundownSelect <RundownSelect
@@ -1,13 +1,13 @@
import { import {
FOLLOW_LOADED_RUNDOWN_ID, FOLLOW_LOADED_RUNDOWN_ID,
getRundownSelectionStorageKey, getCuesheetRundownStorageKey,
resolveSelectedRundownId, resolveSelectedRundownId,
} from '../useRundownSelection'; } from '../useCuesheetRundownSelection';
describe('useRundownSelection helpers', () => { describe('useCuesheetRundownSelection helpers', () => {
it('builds a namespace and project scoped storage key', () => { it('builds a project-scoped storage key', () => {
expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe( expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'rundown-selection:cuesheet:http://localhost:4001:My Project', 'cuesheet-selected-rundown:http://localhost:4001:My Project',
); );
}); });
@@ -1,13 +1,16 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useRundown from '../../../common/hooks-query/useRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal'; import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal); export default memo(EntryEditModal);
function EntryEditModal() { function EntryEditModal({ rundown }: EntryEditModalProps) {
const { data: rundown } = useRundown();
const entryId = useEditModal((state) => state.selectedEntryId); const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection); const closeModal = useEditModal((state) => state.clearSelection);
@@ -14,8 +14,7 @@ import {
import EmptyFill from '../../../common/components/state/EmptyFill'; import EmptyFill from '../../../common/components/state/EmptyFill';
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 { useRundownScope } from '../../../common/context/RundownScopeContext'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; 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';
@@ -42,17 +41,20 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: CuesheetColumnDef[]; columns: CuesheetColumnDef[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode; insertElement?: ReactNode;
}; };
type EditorCuesheetTableProps = CuesheetTableBaseProps & { type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor'; tableRoot: 'editor';
setCuesheetMode?: undefined; setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
}; };
type ViewCuesheetTableProps = CuesheetTableBaseProps & { type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet'; tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
}; };
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
@@ -60,13 +62,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ export default function CuesheetTable({
columns, columns,
cuesheetMode, cuesheetMode,
source,
tableRoot, tableRoot,
setCuesheetMode, setCuesheetMode,
isCurrentRundown,
insertElement, insertElement,
}: CuesheetTableProps) { }: CuesheetTableProps) {
const { isLoaded: isCurrentRundown } = useRundownScope(); const { flatRundown, status, selectedEventId } = source;
const { data: flatRundown, status } = useFlatRundownWithMetadata();
const selectedEventId = useScopedSelectedEventId();
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -105,10 +105,10 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
return null; return null;
} }
const { hideTableSeconds } = table.options.meta.options;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>; const duration = getValue() as number;
return <MutedText numeric>{formatDuration(duration, false)}</MutedText>;
} }
const { handleUpdateTimer } = table.options.meta; const { handleUpdateTimer } = table.options.meta;
@@ -117,7 +117,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
const duration = getValue() as number; const duration = getValue() as number;
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration; const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, hideTableSeconds); const formattedDuration = formatDuration(duration, false);
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { if (!canWrite) {
@@ -2,21 +2,14 @@ import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react'; import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals'; import { serverURL } from '../../externals';
import { useOrderedProjectList } from '../hooks-query/useProjectList';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const; export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
/** each surface keeps its own selection, so panels can point at different rundowns */ export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
export type RundownSelectionNamespace = 'cuesheet'; return `cuesheet-selected-rundown:${server}:${projectFilename}`;
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
} }
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) { export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -24,22 +17,14 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
return FOLLOW_LOADED_RUNDOWN_ID; return FOLLOW_LOADED_RUNDOWN_ID;
} }
/** export function useCuesheetRundownSelection() {
* Persisted choice of which rundown a surface operates on.
* The resolved id is meant to be handed to a rundown scope provider,
* where null means follow whichever rundown is loaded.
*/
export function useRundownSelection(namespace: RundownSelectionNamespace) {
'use memo'; 'use memo';
const { data: projectRundowns } = useProjectRundowns(); const { data: projectRundowns } = useProjectRundowns();
const { const {
data: { lastLoadedProject }, data: { lastLoadedProject },
} = useOrderedProjectList(); } = useOrderedProjectList();
const storageKey = useMemo( const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({ const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey, key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID, defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -53,8 +38,6 @@ export function useRundownSelection(namespace: RundownSelectionNamespace) {
return { return {
loadedRundownId, loadedRundownId,
selectedRundownId, selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns, projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => { setSelectedRundownId: (rundownId: string) => {
startTransition(() => { startTransition(() => {
@@ -69,7 +52,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject }, data: { lastLoadedProject },
} = useOrderedProjectList(); } = useOrderedProjectList();
const navigate = useNavigate(); const navigate = useNavigate();
const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject); const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null }); const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback( return useCallback(
+15 -23
View File
@@ -1,6 +1,5 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList'; import TitleList from './title-list/TitleList';
@@ -22,9 +21,7 @@ export default function Editor() {
<TimerControl /> <TimerControl />
<MessageControl /> <MessageControl />
</div> </div>
<EditableRundownScopeProvider rundownId={null}> <Rundown />
<Rundown />
</EditableRundownScopeProvider>
</div> </div>
); );
} }
@@ -32,17 +29,14 @@ export default function Editor() {
if (layoutMode === EditorLayoutMode.TRACKING) { if (layoutMode === EditorLayoutMode.TRACKING) {
return ( return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}> <div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
{/* the titles and the rundown share a scope, so they follow one cursor */} <div className={styles.rundownLayout}>
<EditableRundownScopeProvider rundownId={null}> <div className={styles.titlesPanel}>
<div className={styles.rundownLayout}> <TitleList mode={AppMode.Run} />
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div> </div>
</EditableRundownScopeProvider> <div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<TrackingPlaybackBar /> <TrackingPlaybackBar />
</div> </div>
); );
@@ -50,16 +44,14 @@ export default function Editor() {
return ( return (
<div id='panels' className={styles.panelContainer}> <div id='panels' className={styles.panelContainer}>
<EditableRundownScopeProvider rundownId={null}> <div className={styles.rundownLayout}>
<div className={styles.rundownLayout}> <div className={styles.titlesPanel}>
<div className={styles.titlesPanel}> <TitleList mode={AppMode.Edit} />
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div> </div>
</EditableRundownScopeProvider> <div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
</div> </div>
); );
} }
@@ -207,10 +207,10 @@ export function searchByText(
* @param activeFilter - a field selected from the filter badges, if any * @param activeFilter - a field selected from the filter badges, if any
*/ */
export default function useFinder(searchValue: string, activeFilter: MaybeString) { export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data } = useFlatRundown(); const { data, rundownId } = useFlatRundown();
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const selectAndRevealEntry = useSelectAndRevealEntry(); const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** The filters offered to the user: the fixed fields plus whatever the project defines */ /** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => { const filters = useMemo<FinderFilter[]>(() => {
@@ -46,6 +46,7 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData} eventData={eventData}
selectedEventId={selectedEventId} selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId} resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/> />
); );
} }
@@ -55,14 +56,21 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[]; eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null; selectedEventId: string | null;
resolvedFollowEventId: string | null; resolvedFollowEventId: string | null;
rundownId: string;
} }
function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) { function TitleListContent({
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
'use memo'; 'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null); const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null); const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry(); const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
// Calculate current event info // Calculate current event info
const currentEventInfo = useMemo(() => { const currentEventInfo = useMemo(() => {
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata(); const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
+2 -2
View File
@@ -1,8 +1,8 @@
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types'; import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useLoadedRundown(); const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown; const { entries } = rundown;
return { return {
+2 -2
View File
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
/> />
<title>ontime</title> <title>Ontime</title>
<style> <style>
body { body {
-webkit-user-select: none; -webkit-user-select: none;
@@ -92,7 +92,7 @@
<body> <body>
<div class="container"> <div class="container">
<img src="../assets/logo.png" /> <img src="../assets/logo.png" />
<h1>ontime · event timers</h1> <h1>Ontime · event timers</h1>
<div class="lds-ellipsis"> <div class="lds-ellipsis">
<div></div> <div></div>
<div></div> <div></div>
@@ -135,6 +135,27 @@ describe('addAutomation()', () => {
const automations = getAutomations(); const automations = getAutomations();
expect(automations[automation.id]).toMatchObject(testData); expect(automations[automation.id]).toMatchObject(testData);
}); });
it('creates an automation and its global triggers together', async () => {
const automation = await addAutomation({ title: 'triggered', filterRule: 'all', filters: [], outputs: [] }, [
{ title: 'triggered — On Start', trigger: TimerLifeCycle.onStart },
{ title: 'triggered — On Finish', trigger: TimerLifeCycle.onFinish },
]);
expect(getAutomations()[automation.id]).toEqual(automation);
expect(getAutomationTriggers()).toEqual([
expect.objectContaining({
title: 'triggered — On Start',
trigger: TimerLifeCycle.onStart,
automationId: automation.id,
}),
expect.objectContaining({
title: 'triggered — On Finish',
trigger: TimerLifeCycle.onFinish,
automationId: automation.id,
}),
]);
});
}); });
describe('editAutomation()', () => { describe('editAutomation()', () => {
@@ -225,4 +246,44 @@ describe('deleteAutomation()', () => {
const removed = getAutomations(); const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0); expect(Object.keys(removed).length).toEqual(0);
}); });
it('takes the automation global triggers with it, and leaves the others alone', async () => {
const doomed = Object.keys(getAutomations())[0];
const survivor = await addAutomation({ title: 'survivor', filterRule: 'all', filters: [], outputs: [] });
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId: doomed });
await addTrigger({ title: 'on finish', trigger: TimerLifeCycle.onFinish, automationId: doomed });
await addTrigger({ title: 'keep me', trigger: TimerLifeCycle.onStart, automationId: survivor.id });
await deleteAutomation({}, doomed);
// a trigger pointing at nothing never fires, so it must not outlive its automation
expect(getAutomationTriggers()).toEqual([expect.objectContaining({ title: 'keep me' })]);
expect(Object.keys(getAutomations())).toEqual([survivor.id]);
});
it('refuses an automation attached to an event, and keeps its triggers', async () => {
const automationId = Object.keys(getAutomations())[0];
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId });
const projectRundowns: ProjectRundowns = {
'rundown-1': {
id: 'rundown-1',
title: 'Rundown 1',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
}),
},
revision: 1,
},
};
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
expect(getAutomationTriggers()).toHaveLength(1);
expect(Object.keys(getAutomations())).toEqual([automationId]);
});
}); });
@@ -44,6 +44,14 @@ describe('parseTemplateNested()', () => {
const result = parseTemplateNested(testString, mockState); const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected); expect(result).toStrictEqual(expected);
}); });
it('URL-encodes an explicitly marked variable', () => {
const result = parseTemplateNested('http://example.com/?title={{url:event.title}}', {
event: { title: 'Keynote & Roadmap #1' },
});
expect(result).toBe('http://example.com/?title=Keynote%20%26%20Roadmap%20%231');
});
}); });
describe('parseNestedTemplate() -> resolveAliasData()', () => { describe('parseNestedTemplate() -> resolveAliasData()', () => {

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