Compare commits

...

5 Commits

Author SHA1 Message Date
Carlos Valente 982cb8e495 fix(rundown): preserve revision and runtime semantics
Advance swapped-entry revisions correctly and rename loaded rundowns without restarting playback, while documenting the shared revision invariant.
2026-09-13 20:23:53 +02:00
Carlos Valente 48c76947c5 refactor(rundown): bind each surface to its data source
Editable panels now share a rundown scope for data, selection, and mutations; runtime-only views resolve the loaded rundown explicitly. This also prevents stale data while switching scopes and provides the Operator editor with its mutation scope.
2026-09-13 20:23:53 +02:00
Carlos Valente 2a992a8595 refactor(rundown): add scoped rundown data primitives
Centralise rundown identity, queries, entry actions, and selection state so a surface can safely target either the loaded or a background rundown.
2026-09-13 20:23:34 +02:00
Carlos Valente b07544ab84 feat(automation): improve definition and trigger editing 2026-09-13 15:40:12 +02:00
Carlos Valente 79c21daf73 fix(automation): validate template-aware output targets 2026-09-13 15:40:12 +02:00
78 changed files with 1782 additions and 931 deletions
+3
View File
@@ -3,6 +3,7 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
@@ -112,7 +113,9 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper>
}
/>
@@ -0,0 +1,25 @@
import { maybeAxiosError } from '../utils';
describe('maybeAxiosError', () => {
it('shows the validation message without echoing the submitted value', () => {
const error = {
isAxiosError: true,
response: {
statusText: 'Unprocessable Entity',
data: {
errors: [
{
type: 'field',
value: { title: 'Automation definition', outputs: [{ targetIP: 'not a host' }] },
msg: 'Invalid OSC target',
path: '',
location: 'body',
},
],
},
},
};
expect(maybeAxiosError(error)).toBe('Unprocessable Entity: Invalid OSC target');
});
});
+8
View File
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
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
export const apiEntryUrl = `${serverURL}/data`;
+9
View File
@@ -18,6 +18,15 @@ export function maybeAxiosError(error: unknown) {
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else if ('errors' in data && Array.isArray(data.errors)) {
const firstError = data.errors.at(0);
data =
typeof firstError === 'object' &&
firstError !== null &&
'msg' in firstError &&
typeof firstError.msg === 'string'
? firstError.msg
: JSON.stringify(data);
} else {
data = JSON.stringify(data);
}
@@ -0,0 +1,13 @@
import { TimerLifeCycle } from 'ontime-types';
import { getLifecycleLabel } from '../timerLifecycle';
describe('getLifecycleLabel', () => {
it('returns the shared label for known lifecycle values', () => {
expect(getLifecycleLabel(TimerLifeCycle.onClock)).toBe('Every second');
});
it('keeps unknown lifecycle values visible', () => {
expect(getLifecycleLabel('future-lifecycle')).toBe('future-lifecycle');
});
});
@@ -0,0 +1,17 @@
import { TimerLifeCycle } from 'ontime-types';
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',
};
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}
@@ -0,0 +1,24 @@
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>;
}
@@ -0,0 +1,77 @@
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;
}
@@ -0,0 +1,81 @@
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 };
}
+31 -100
View File
@@ -1,62 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useRundownScope } from '../context/RundownScopeContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
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,
};
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* 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`.
* Normalised rundown data for the rundown of the enclosing scope
*/
export default function useRundown() {
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
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 };
/**
* Runtime state only describes the loaded rundown,
* a scope pointed elsewhere must not show a playing event
*/
export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId();
return isLoaded ? selectedEventId : null;
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const selectedEventId = useScopedSelectedEventId();
// key on the fields the derivation reads, a revision only change must not churn the list
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
@@ -66,41 +42,23 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const selectedEventId = useScopedSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
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 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
*/
@@ -110,30 +68,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return 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 };
}
@@ -0,0 +1,65 @@
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 })),
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);
}
@@ -1,54 +0,0 @@
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],
);
}
@@ -1,13 +1,13 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
getRundownSelectionStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
} from '../useRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
describe('useRundownSelection helpers', () => {
it('builds a namespace and project scoped storage key', () => {
expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe(
'rundown-selection:cuesheet:http://localhost:4001:My Project',
);
});
+47 -18
View File
@@ -8,7 +8,6 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -32,9 +31,10 @@ import {
swapEventData,
} from 'ontime-utils';
import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import { getRundownCacheKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -52,6 +52,7 @@ import {
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -64,22 +65,38 @@ export type EventOptions = Partial<{
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 = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
* Gather utilities for actions on entries in the rundown of the enclosing scope.
*/
export const useEntryActions = () => useEntryActionsForRundown(undefined);
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -93,12 +110,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -325,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
const previousEntry = newRundown[eventId];
if (previousEntry) {
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id,
title: previousData.title,
@@ -340,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Return a context with the previous and new events
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
onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) {
@@ -512,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
newRundown[eventId] = patchEntry(event, data.data);
}
}
});
@@ -2,14 +2,21 @@ import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
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 function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
/** each surface keeps its own selection, so panels can point at different rundowns */
export type RundownSelectionNamespace = 'cuesheet';
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -17,14 +24,22 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
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';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const storageKey = useMemo(
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -38,6 +53,8 @@ export function useCuesheetRundownSelection() {
return {
loadedRundownId,
selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
@@ -52,7 +69,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
@@ -0,0 +1,64 @@
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,14 +1,21 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
};
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
}));
@@ -0,0 +1,158 @@
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,26 @@
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;
};
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);
}
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 }));
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return;
}
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision === cachedRundown?.revision) {
// we already have the latest change
// we already have this change, or something newer
// messages can arrive after a refetch has already brought in a later revision
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
return;
}
@@ -30,7 +30,8 @@ $content-max-width: 1280px;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
overflow-y: auto;
// Own both scroll axes so sticky table headers stay anchored to the panel viewport.
overflow: auto;
flex-grow: 1;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
@@ -5,9 +5,8 @@
font-size: calc(1rem - 1px);
color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem;
padding-right: 0.5rem;
h3 {
font-size: 1rem;
@@ -27,10 +26,7 @@
}
.titleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
.filterSection {
display: grid;
grid-gap: 0.5rem;
@@ -41,10 +37,7 @@
.titleSection,
.ruleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
.filterSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
@@ -61,26 +54,85 @@
}
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
grid-template-columns: minmax(11rem, 1.25fr) minmax(10rem, 1fr) minmax(13rem, 1.75fr) auto;
align-items: end;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
@media (max-width: $min-tablet) {
.filterSection {
grid-template-columns: 1fr 1fr auto;
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
label:last-of-type {
grid-column: 1 / -1;
}
}
}
.outputCard {
.card {
border: 1px solid $white-10;
border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem;
border-radius: $component-border-radius-md;
background-color: $black-10;
label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
color: $secondary-text-gray;
font-size: $aux-text-size;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: $green-400;
font-size: $aux-text-size;
}
.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,14 +1,5 @@
import {
Automation,
AutomationDTO,
HTTPOutput,
OSCOutput,
OntimeAction,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
@@ -16,25 +7,28 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { startsWithHttp } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils';
import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
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';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
const testFeedbackDuration = 2000;
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -46,13 +40,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const { data } = useCustomFields();
const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]);
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const [submitError, setSubmitError] = useState<string>();
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const {
control,
handleSubmit,
getValues,
register,
setError,
setFocus,
setValue,
watch,
@@ -93,6 +89,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title');
}, [setFocus]);
// Clear delayed output-test feedback when the modal unmounts.
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 = () => {
appendFilter({ field: '', operator: 'equals', value: '' });
};
@@ -106,57 +124,33 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' });
};
const handleAddnewOntimeAction = () => {
const handleAddNewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' });
};
const handleTestOSCOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OSCOutput;
if (!values.targetIP || !values.targetPort || !values.address) {
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;
}
await testOutput({
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 {
const values = getValues(`outputs.${index}`) as HTTPOutput;
if (!values.url) {
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
await testOutput({
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestOntimeAction = async (index: number) => {
reportTest(key, { status: 'sending' });
try {
const values = getValues(`outputs.${index}`) as OntimeAction;
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
await testOutput(values);
reportTest(key, { status: 'ok', message: 'Request sent' });
} catch (error) {
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
}
};
const onSubmit = async (values: AutomationDTO) => {
setSubmitError(undefined);
if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values });
} else {
@@ -169,7 +163,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
setSubmitError(maybeAxiosError(error));
}
}
@@ -178,12 +172,43 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
setSubmitError(maybeAxiosError(error));
}
}
};
const canSubmit = !isSubmitting && isDirty && isValid;
const addOutputMenu = (
<DropdownMenu
render={<Button />}
items={[
...(isOntimeCloud
? []
: [
{
type: 'item' as const,
label: 'OSC',
description: 'Send an OSC message to a device on the network',
onClick: handleAddNewOSCOutput,
},
]),
{
type: 'item' as const,
label: 'HTTP',
description: 'Call a URL, for webhooks and REST APIs',
onClick: handleAddNewHTTPOutput,
},
{
type: 'item' as const,
label: 'Ontime action',
description: 'Change something inside Ontime, like a message or an aux timer',
onClick: handleAddNewOntimeAction,
},
]}
>
Add output <IoAdd />
</DropdownMenu>
);
return (
<Modal
@@ -202,7 +227,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
placeholder='Automation title'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
@@ -223,6 +248,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ value: 'any', label: 'Any filter passes' },
]}
/>
<Panel.Description>
All filters pass requires every condition to match. Any filter passes requires at least one match.
</Panel.Description>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
@@ -264,11 +292,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
options={operators}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
@@ -305,150 +329,52 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<span>
Use Ontime runtime data in these fields with template strings. Type{' '}
<Panel.Highlight>{'{{'}</Panel.Highlight> to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</span>
</Info>
{fieldOutputs.length === 0 && (
<Panel.EmptyState
title='This automation does nothing yet'
description='An automation without outputs will be triggered, but it has nothing to send.'
action={addOutputMenu}
/>
)}
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
const cardProps = {
testState: testResults[output.id],
onTest: () => handleTest(index, output.id),
onDelete: () => removeOutput(index),
};
const rowErrors = getOutputErrors(index);
if (isOSCOutput(output)) {
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<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>
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)}
<OutputCard
key={output.id}
label='OSC'
kindClass={style.tagOsc}
summary={watch(`outputs.${index}.address`)}
unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
{...cardProps}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
}
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>
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
<OntimeActionForm
value={output.action}
index={index}
@@ -456,44 +382,19 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<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>
/>
</OutputCard>
);
}
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
{fieldOutputs.length > 0 && addOutputMenu}
</div>
</form>
}
footerElements={
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
<Panel.Section>
<Info>
<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>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
@@ -0,0 +1,3 @@
.table {
min-width: 36rem;
}
@@ -9,9 +9,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
import style from './AutomationsList.module.scss';
const automationPlaceholder: AutomationDTO = {
title: '',
filterRule: 'all',
@@ -60,19 +63,18 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
<Info type='warning'>
Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
</Info>
)}
<Panel.Table>
<Panel.Table className={style.table}>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th style={{ width: '15%' }}>Sends</th>
<th />
</tr>
</thead>
@@ -80,7 +82,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
{!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
description='Create a reusable definition, then attach it to a global or an event trigger.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
@@ -100,7 +102,15 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<td>
{automations[automationId].outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
summariseOutputs(automations[automationId].outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -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>
);
}
@@ -1,10 +1,11 @@
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 Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps {
index: number;
register: UseFormRegister<AutomationDTO>;
rowErrors?: {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
rowErrors?: OutputErrors;
value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>;
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
setValue,
rowErrors,
value,
children,
watch,
}: PropsWithChildren<OntimeActionFormProps>) {
}: OntimeActionFormProps) {
const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => {
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
};
return (
<div className={style.actionSection}>
<>
<label>
Action
<Select
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && (
<>
<label>
<label className={style.spanFull}>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
@@ -105,7 +99,7 @@ export default function OntimeActionForm({
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
<label className={style.spanFull}>
Visibility
<Select
onValueChange={(value) => {
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
</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,60 @@
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;
unavailableReason?: string;
onTest: () => void;
onDelete: () => void;
children: ReactNode;
}
export default function OutputCard({
label,
kindClass,
summary,
testState,
unavailableReason,
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>
)}
{unavailableReason ? (
<Tag variant='warning'>{unavailableReason}</Tag>
) : (
<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>
);
}
@@ -0,0 +1,25 @@
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.titleField {
display: block;
}
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
label {
min-width: 0;
}
}
@media (max-width: $min-tablet) {
.fields {
grid-template-columns: 1fr;
}
}
@@ -11,6 +11,8 @@ import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import style from './TriggerForm.module.scss';
const formId = 'trigger-form';
interface TriggerFormProps {
@@ -83,18 +85,20 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label>
Title
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<label className={style.titleField}>
<Panel.Description>Title</Panel.Description>
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={trigger?.title}
placeholder='Trigger title'
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<div className={style.fields}>
<label>
Lifecycle trigger
<Panel.Description>When to run</Panel.Description>
<Select
value={watch('trigger')}
onValueChange={(value) => {
@@ -107,7 +111,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Panel.Description>Automation</Panel.Description>
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
@@ -119,6 +123,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</div>
</form>
}
footerElements={
@@ -0,0 +1,3 @@
.table {
min-width: 36rem;
}
@@ -1,5 +1,5 @@
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation';
@@ -12,6 +12,8 @@ import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
import style from './TriggersList.module.scss';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
@@ -50,6 +52,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
};
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
const canAdd = Object.keys(automations).length > 0;
@@ -66,7 +72,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
/>
)}
<Panel.SubHeader>
Manage triggers
Global triggers
<Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd />
</Button>
@@ -74,17 +80,25 @@ export default function TriggersList({ triggers, automations, enabledAutomations
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
<Info type='warning'>
Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
</Info>
)}
<Info>Actions in this section affect the entire project runtime, not just a single event.</Info>
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance
issues.
You have created multiple links between the same trigger and automation. Duplicate combinations will only
fire once per lifecycle event.
</Panel.Error>
)}
<Panel.Table>
{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.Table className={style.table}>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
@@ -100,7 +114,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
: 'Create an automation definition first, then add a global trigger to decide when it should run.'
}
action={
canAdd && (
@@ -111,19 +125,16 @@ export default function TriggersList({ triggers, automations, enabledAutomations
}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
{triggers.map((trigger, index) => (
<TriggersListItem
key={trigger.id}
automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
))}
{deleteError && (
<tr>
<td colSpan={5}>
@@ -16,6 +16,7 @@ interface TriggersListItemProps {
export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const automation = automations[trigger.automationId];
return (
<tr data-warn={duplicate}>
@@ -30,9 +31,7 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td>
<td>{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil />
@@ -1,6 +1,19 @@
import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils';
import { checkDuplicates, operators } from '../automationUtils';
describe('automation form options', () => {
it('offers the complete filter operator contract', () => {
expect(operators.map(({ value }) => value)).toEqual([
'equals',
'not_equals',
'contains',
'not_contains',
'greater_than',
'less_than',
]);
});
});
describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => {
@@ -1,21 +1,33 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { lifecycleLabels } from '../../../../common/constants/timerLifecycle';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
value: TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
{ id: 7, label: 'On Finish', value: 'onFinish' },
{ id: 8, label: 'On Warning', value: 'onWarning' },
{ id: 9, label: 'On Danger', value: 'onDanger' },
{ label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
{ label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
{ label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
{ label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
{ label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
{ label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
{ label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
{ label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
{ label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
];
export type OutputErrors = Partial<Record<string, { message?: string }>>;
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: 'not_contains', label: 'does not contain' },
{ value: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
];
/**
@@ -66,19 +78,16 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
* We warn the user if they have created multiple links between the same automation and a trigger
*/
export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {};
const duplicates = [];
const seen = new Set<string>();
const duplicates: number[] = [];
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
for (const [index, trigger] of triggers.entries()) {
const key = `${trigger.trigger}:${trigger.automationId}`;
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
duplicates.push(i);
if (seen.has(key)) {
duplicates.push(index);
} else {
triggersMap[trigger.trigger].push(trigger.automationId);
seen.add(key);
}
}
return duplicates.length > 0 ? duplicates : undefined;
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { data: currentRundown } = useLoadedRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -1,10 +1,13 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import Operator from './Operator';
export default function OperatorExport() {
return (
<ProtectRoute permission='operator'>
<EditableRundownScopeProvider rundownId={null}>
<Operator />
</EditableRundownScopeProvider>
</ProtectRoute>
);
}
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import {
useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const group = useLoadedEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
const entry = useLoadedEntry(id) as OntimeEvent | null;
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 { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData();
const { data: rundownData } = useLoadedRundownAuxData();
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
+5 -1
View File
@@ -67,7 +67,11 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -5,9 +5,6 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
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 { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -39,11 +36,9 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
@@ -60,7 +55,6 @@ function RundownExport() {
</div>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -70,7 +64,6 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
@@ -93,7 +86,6 @@ function RundownExport() {
</div>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -105,8 +97,6 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -115,7 +105,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog />
</div>
);
@@ -1,15 +1,22 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
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);
function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -15,7 +15,7 @@
.triggerHeader {
display: grid;
grid-template-columns: 8rem 1fr 2rem;
grid-template-columns: 8rem 1fr auto 2rem;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: $aux-text-size;
@@ -25,7 +25,7 @@
.trigger {
padding: 0.5rem 0.75rem;
display: grid;
grid-template-columns: 8rem 1fr 2rem;
grid-template-columns: 8rem 1fr auto 2rem;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
@@ -41,6 +41,12 @@
}
}
.outputTags {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.duplicateMessage {
padding-left: 0.75rem;
font-size: $aux-text-size;
@@ -6,8 +6,11 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
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 useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss';
@@ -27,7 +30,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
label: title,
}));
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 seen = new Map<string, string>();
@@ -76,8 +79,10 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
<div className={style.triggerHeader}>
<span>Lifecycle</span>
<span>Automation</span>
<span>Sends</span>
</div>
{triggers.map((trigger) => {
const automation = automationSettings.automations[trigger.automationId];
const isDuplicate = duplicateIds.has(trigger.id);
const lifecycleOptions = isDuplicate
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
@@ -103,6 +108,15 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
}}
options={automationOptions}
/>
<div className={style.outputTags}>
{automation ? (
summariseOutputs(automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
) : (
<Tag variant='warning'>Missing automation</Tag>
)}
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
<IoTrash />
</IconButton>
@@ -2,6 +2,7 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
@@ -26,6 +27,7 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
@@ -43,12 +45,17 @@ export function useRundownCommands({
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
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;
const elementToCopy = entries[entryCopyId];
@@ -65,7 +72,7 @@ export function useRundownCommands({
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -74,7 +81,7 @@ export function useRundownCommands({
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -85,7 +92,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry],
[entries, flatOrder, clone, reorderEntry, rundownId],
);
/**
@@ -1,9 +1,6 @@
import { memo, useEffect, useMemo } from 'react';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
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 CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -15,8 +12,6 @@ function RundownTable() {
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -32,10 +27,8 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
}
@@ -1,155 +1,15 @@
import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { useStore } from 'zustand';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import type { EventSelectionStore } from '../../common/stores/eventSelectionStore';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
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 { getSelectionMode } from '../../common/stores/eventSelectionStore';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
* Selection and cursor for the rundown of the enclosing scope
*/
export const useEventSelection = create<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 = 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';
export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T {
const { selectionStore } = useRundownScope();
return useStore(selectionStore, selector);
}
@@ -1,6 +1,7 @@
import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
parent?: MaybeString;
};
export function useSelectAndRevealEntry(rundownId: string) {
export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -12,7 +12,7 @@ import {
useState,
} from 'react';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options';
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = usePartialRundown(filterCallback);
const { data: events } = useLoadedPartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -3,35 +3,31 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import { useRundownSelection } from '../../common/hooks/useRundownSelection';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
'use memo';
const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } =
useRundownSelection('cuesheet');
useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked();
return (
<EntryActionsProvider actions={actions}>
<EditableRundownScopeProvider rundownId={scopedRundownId}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal rundown={source.rundown} />
<EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
@@ -42,13 +38,12 @@ export default function CuesheetPage() {
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EntryActionsProvider>
</EditableRundownScopeProvider>
);
}
@@ -3,19 +3,18 @@ import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
@@ -24,15 +23,14 @@ interface CuesheetTableWrapperProps {
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { isLoaded } = useRundownScope();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded });
const { data: customFields } = useCustomFields();
const columns = useMemo(
@@ -44,11 +42,9 @@ function CuesheetTableWrapper({
<CuesheetDnd columns={columns}>
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
@@ -1,16 +1,13 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import useRundown from '../../../common/hooks-query/useRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal({ rundown }: EntryEditModalProps) {
function EntryEditModal() {
const { data: rundown } = useRundown();
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -14,7 +14,8 @@ import {
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -41,20 +42,17 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: CuesheetColumnDef[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
};
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
@@ -62,13 +60,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { isLoaded: isCurrentRundown } = useRundownScope();
const { data: flatRundown, status } = useFlatRundownWithMetadata();
const selectedEventId = useScopedSelectedEventId();
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
+8
View File
@@ -1,5 +1,6 @@
import { lazy } from 'react';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList';
@@ -21,7 +22,9 @@ export default function Editor() {
<TimerControl />
<MessageControl />
</div>
<EditableRundownScopeProvider rundownId={null}>
<Rundown />
</EditableRundownScopeProvider>
</div>
);
}
@@ -29,6 +32,8 @@ export default function Editor() {
if (layoutMode === EditorLayoutMode.TRACKING) {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
{/* the titles and the rundown share a scope, so they follow one cursor */}
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
@@ -37,6 +42,7 @@ export default function Editor() {
<Rundown />
</div>
</div>
</EditableRundownScopeProvider>
<TrackingPlaybackBar />
</div>
);
@@ -44,6 +50,7 @@ export default function Editor() {
return (
<div id='panels' className={styles.panelContainer}>
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
@@ -52,6 +59,7 @@ export default function Editor() {
<Rundown />
</div>
</div>
</EditableRundownScopeProvider>
</div>
);
}
@@ -207,10 +207,10 @@ export function searchByText(
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data, rundownId } = useFlatRundown();
const { data } = useFlatRundown();
const { data: customFields } = useCustomFields();
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
const selectAndRevealEntry = useSelectAndRevealEntry();
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
@@ -46,7 +46,6 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData}
selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/>
);
}
@@ -56,21 +55,14 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
resolvedFollowEventId: string | null;
rundownId: string;
}
function TitleListContent({
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
const selectAndRevealEntry = useSelectAndRevealEntry();
// Calculate current event info
const currentEventInfo = useMemo(() => {
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
+2 -2
View File
@@ -1,8 +1,8 @@
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { data: rundown, status: rundownStatus } = useLoadedRundown();
const { entries } = rundown;
return {
@@ -0,0 +1,84 @@
import type { Request, Response } from 'express';
import type { Automation, ErrorResponse } from 'ontime-types';
import { editAutomation, postAutomation } from '../automation.controller.js';
import * as automationDao from '../automation.dao.js';
vi.mock('../automation.dao.js', () => ({
addAutomation: vi.fn(),
editAutomation: vi.fn(),
}));
function makeResponse() {
return {
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response<Automation | ErrorResponse>;
}
const requestBody = {
title: 'OSC definition',
filterRule: 'all',
filters: [],
outputs: [{ type: 'osc', targetIP: ' 127.0.0.1 ', targetPort: 53000, address: '/test', args: '' }],
};
describe('automation controllers', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(automationDao.addAutomation).mockImplementation(async (automation) => ({ id: 'new-id', ...automation }));
vi.mocked(automationDao.editAutomation).mockImplementation(async (id, automation) => ({ id, ...automation }));
});
it('persists normalized outputs when creating an automation', async () => {
const request = { body: requestBody } as Request;
await postAutomation(request, makeResponse());
expect(automationDao.addAutomation).toHaveBeenCalledWith(
expect.objectContaining({ outputs: [expect.objectContaining({ targetIP: '127.0.0.1' })] }),
);
});
it('persists normalized outputs when editing an automation', async () => {
const request = { body: requestBody, params: { id: 'automation-id' } } as unknown as Request;
await editAutomation(request, makeResponse());
expect(automationDao.editAutomation).toHaveBeenCalledWith(
'automation-id',
expect.objectContaining({ outputs: [expect.objectContaining({ targetIP: '127.0.0.1' })] }),
);
});
it('persists definitions using the not_contains filter operator', async () => {
const request = {
body: {
...requestBody,
filters: [{ field: 'eventNow.title', operator: 'not_contains', value: 'break' }],
},
} as Request;
await postAutomation(request, makeResponse());
expect(automationDao.addAutomation).toHaveBeenCalledWith(
expect.objectContaining({ filters: [expect.objectContaining({ operator: 'not_contains' })] }),
);
});
it('rejects trigger bindings when editing a definition', async () => {
const request = {
body: { ...requestBody, triggers: [{ trigger: 'onStart', automationId: 'other-definition' }] },
params: { id: 'automation-id' },
} as unknown as Request;
const response = makeResponse();
await editAutomation(request, response);
expect(automationDao.editAutomation).not.toHaveBeenCalled();
expect(response.status).toHaveBeenCalledWith(400);
expect(response.send).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Automation definitions cannot include triggers' }),
);
});
});
@@ -146,7 +146,7 @@ describe('editAutomation()', () => {
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [],
outputs: [makeOSCAction(), makeHTTPAction()],
});
await addAutomation({
title: 'test-http',
@@ -164,7 +164,7 @@ describe('editAutomation()', () => {
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
outputs: [makeOSCAction(), makeHTTPAction()],
});
const editedOSC = await editAutomation(firstAutomation.id, {
@@ -179,9 +179,34 @@ describe('editAutomation()', () => {
title: 'edited-title',
filterRule: 'any',
filters: expect.any(Array),
outputs: expect.any(Array),
outputs: [],
});
});
it('replaces all outputs when an automation definition is edited', async () => {
const editedWithOneOutput = await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'any',
filters: [],
outputs: [makeHTTPAction()],
});
expect(editedWithOneOutput.outputs).toEqual([makeHTTPAction()]);
});
it('preserves global triggers when a definition is edited', async () => {
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id });
await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'all',
filters: [],
outputs: [],
});
expect(getAutomationTriggers()).toEqual([
expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onStart }),
]);
});
});
describe('deleteAutomation()', () => {
@@ -225,4 +250,35 @@ describe('deleteAutomation()', () => {
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('refuses an automation attached to a global trigger', async () => {
const automationId = Object.keys(getAutomations())[0];
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId });
await expect(deleteAutomation({}, automationId)).rejects.toThrow(/used in trigger/);
expect(getAutomationTriggers()).toHaveLength(1);
expect(getAutomations()[automationId]).toBeDefined();
});
it('refuses an automation attached to an event', async () => {
const automationId = Object.keys(getAutomations())[0];
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(getAutomations()[automationId]).toBeDefined();
});
});
@@ -1,7 +1,17 @@
import { ProjectRundowns, TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { isAutomationUsed, isHostname, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('isHostname()', () => {
it.each(['localhost', 'qlab', 'osc.example.com', 'osc-target.example'])('accepts %s', (hostname) => {
expect(isHostname(hostname)).toBe(true);
});
it.each(['not a host', '-osc.example', 'osc-.example', 'osc..example', 'osc.example.'])('rejects %s', (hostname) => {
expect(isHostname(hostname)).toBe(false);
});
});
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
@@ -1,17 +1,25 @@
import { parseOutput } from '../automation.validation.js';
import { parseAutomation, parseOutput } from '../automation.validation.js';
describe('parseAutomation', () => {
it('rejects trigger bindings from definition payloads', () => {
expect(() =>
parseAutomation({ title: 'Definition', filterRule: 'all', filters: [], outputs: [], triggers: [] }),
).toThrow('Automation definitions cannot include triggers');
});
});
describe('parseOutput', () => {
describe('handles OSC outputs', () => {
it('parses a valid payload', () => {
const payload = {
type: 'osc',
targetIP: 'localhost',
targetIP: ' qlab ',
targetPort: 1234,
address: '/test',
args: 'test',
};
const result = parseOutput(payload);
expect(result).toStrictEqual(payload);
expect(result).toStrictEqual({ ...payload, targetIP: 'qlab' });
});
it('throws on a invalid payload', () => {
@@ -24,6 +32,33 @@ describe('parseOutput', () => {
};
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
});
it('rejects invalid targets and ports', () => {
expect(() =>
parseOutput({ type: 'osc', targetIP: 'not a host', targetPort: 53000, address: '/test', args: '' }),
).toThrow('Invalid OSC target');
expect(() =>
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 70000, address: '/test', args: '' }),
).toThrow('Invalid OSC port');
});
it('allows runtime templates in a target hostname', () => {
expect(
parseOutput({
type: 'osc',
targetIP: '{{eventNow.custom.oscTarget}}',
targetPort: 53000,
address: '/test',
args: '',
}),
).toMatchObject({ targetIP: '{{eventNow.custom.oscTarget}}' });
});
it('rejects IPv6 targets', () => {
expect(() =>
parseOutput({ type: 'osc', targetIP: '::1', targetPort: 53000, address: '/test', args: '' }),
).toThrow('Invalid OSC target');
});
});
describe('handles HTTP outputs', () => {
it('parses a valid payload', () => {
@@ -41,6 +76,25 @@ describe('parseOutput', () => {
};
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
});
it('rejects malformed and unsupported URLs', () => {
expect(() => parseOutput({ type: 'http', url: 'localhost:3000/hook' })).toThrow('Invalid HTTP URL');
expect(() => parseOutput({ type: 'http', url: 'ftp://example.com/hook' })).toThrow('Invalid HTTP URL');
});
it('allows runtime templates in HTTP URLs', () => {
expect(parseOutput({ type: 'http', url: 'http://{{eventNow.customFields.webhookHost}}/hook' })).toEqual({
type: 'http',
url: 'http://{{eventNow.customFields.webhookHost}}/hook',
});
});
it('allows a runtime template for a complete HTTP URL', () => {
expect(parseOutput({ type: 'http', url: '{{eventNow.customFields.webhookUrl}}' })).toEqual({
type: 'http',
url: '{{eventNow.customFields.webhookUrl}}',
});
});
});
describe('handles Ontime outputs', () => {
it('parses a valid payload', () => {
@@ -0,0 +1,57 @@
import { runtimeStorePlaceholder } from 'ontime-types';
const { send } = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock('node:dgram', () => ({
createSocket: vi.fn(() => ({ send })),
}));
import { emitOSC } from '../clients/osc.client.js';
describe('emitOSC()', () => {
beforeEach(() => {
send.mockClear();
});
it('resolves templates in the target host before sending', () => {
emitOSC(
{
type: 'osc',
targetIP: '{{eventNow.custom.oscTarget}}',
targetPort: 53000,
address: '/cue/start',
args: '',
},
{
...runtimeStorePlaceholder,
eventNow: {
id: 'current-event',
type: 'event',
cue: '1',
title: 'Opening',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: { oscTarget: '192.0.2.10' },
},
},
);
expect(send).toHaveBeenCalledWith(
expect.anything(),
0,
expect.any(Number),
53000,
'192.0.2.10',
expect.any(Function),
);
});
});
@@ -6,7 +6,7 @@ import { oscServer } from '../../adapters/OscAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js';
import { parseOutput } from './automation.validation.js';
import { parseAutomation, parseOutput } from './automation.validation.js';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.status(200).json(automationDao.getAutomationSettings());
@@ -75,12 +75,8 @@ export async function deleteTrigger(req: Request, res: Response<void | ErrorResp
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = await automationDao.addAutomation({
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
outputs: req.body.outputs,
});
const automation = parseAutomation(req.body);
const newAutomation = await automationDao.addAutomation(automation);
res.status(201).send(newAutomation);
} catch (error) {
const message = getErrorMessage(error);
@@ -90,12 +86,8 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = await automationDao.editAutomation(req.params.id, {
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
outputs: req.body.outputs,
});
const automation = parseAutomation(req.body);
const newAutomation = await automationDao.editAutomation(req.params.id, automation);
res.status(200).send(newAutomation);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,4 +1,5 @@
import {
AutomationFilter,
EntryId,
FilterRule,
MaybeNumber,
@@ -11,10 +12,16 @@ import {
import { getPropertyFromPath, millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils';
import type { OscArgInput, OscArgOrArrayInput } from 'osc-min';
type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains';
type FilterOperator = AutomationFilter['operator'];
const hostnameRegex = /^(?=.{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;
export function isHostname(value: string): boolean {
return hostnameRegex.test(value);
}
export function isFilterOperator(value: string): value is FilterOperator {
return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains'].includes(value);
return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'not_contains'].includes(value);
}
export function isFilterRule(value: string): value is FilterRule {
@@ -1,6 +1,8 @@
import { body, oneOf, param } from 'express-validator';
import { isIP } from 'node:net';
import { body, param } from 'express-validator';
import {
Automation,
AutomationDTO,
AutomationFilter,
AutomationOutput,
HTTPOutput,
@@ -12,7 +14,7 @@ import {
import * as assert from '../../utils/assert.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
import { isFilterOperator, isFilterRule, isHostname, isOntimeActionAction } from './automation.utils.js';
export const validateAutomationSettings = [
body('enabledAutomations').isBoolean(),
@@ -56,10 +58,14 @@ export const validateAutomationPatch = [
/**
* Parses and validates a use given automation
*/
export function parseAutomation(maybeAutomation: unknown): Automation {
export function parseAutomation(maybeAutomation: unknown): AutomationDTO {
assert.isObject(maybeAutomation);
assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']);
if ('triggers' in maybeAutomation) {
throw new Error('Automation definitions cannot include triggers');
}
const { title, filterRule, filters, outputs } = maybeAutomation;
assert.isString(title);
assert.isString(filterRule);
@@ -70,12 +76,12 @@ export function parseAutomation(maybeAutomation: unknown): Automation {
validateFilters(filters);
assert.isArray(outputs);
validateOutput(outputs);
const parsedOutputs = outputs.map(parseOutput);
return maybeAutomation as Automation;
return { title, filterRule, filters, outputs: parsedOutputs };
}
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
function validateFilters(filters: Array<unknown>): asserts filters is AutomationFilter[] {
filters.forEach((condition) => {
assert.isObject(condition);
@@ -92,47 +98,9 @@ function validateFilters(filters: Array<unknown>): filters is AutomationFilter[]
throw new Error(`Invalid automation: unhandled filter type ${typeof value}`);
}
});
return true;
}
function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
output.forEach((payload) => {
parseOutput(payload);
});
return true;
}
export const validateTestPayload = [
body('type').isIn(['osc', 'http', 'ontime']),
// validation for OSC message
oneOf([
body('targetIP').if(body('type').equals('osc')).isIP(),
body('targetIP').if(body('type').equals('osc')).isFQDN(),
body('targetIP').if(body('type').equals('osc')).equals('localhost'),
]),
body('targetPort').if(body('type').equals('osc')).isPort(),
body('address').if(body('type').equals('osc')).isString().trim(),
body('args').if(body('type').equals('osc')).isString().trim(),
// validation for HTTP message
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
// validation for Ontime actions
body('action').if(body('type').equals('ontime')).isString().trim(),
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
body('visible').if(body('type').equals('ontime')).optional().isBoolean(),
// secondary source can be a enum case or null to clear it
body('secondarySource')
.if(body('type').equals('ontime'))
.optional({ nullable: true })
.if((value) => value !== null)
.isString()
.trim(),
requestValidationFunction,
];
export const validateTestPayload = [body().custom(parseOutput), requestValidationFunction];
/**
* Sanitises an output object
@@ -163,9 +131,22 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
assert.isString(maybeOSCOutput.address);
assert.isString(maybeOSCOutput.args);
const targetIP = maybeOSCOutput.targetIP.trim();
const target = replaceTemplatesForValidation(targetIP, 'template.local');
if (isIP(target) !== 4 && !isHostname(target)) {
throw new Error('Invalid OSC target');
}
if (
!Number.isInteger(maybeOSCOutput.targetPort) ||
maybeOSCOutput.targetPort < 1 ||
maybeOSCOutput.targetPort > 65535
) {
throw new Error('Invalid OSC port');
}
return {
type: 'osc',
targetIP: maybeOSCOutput.targetIP,
targetIP,
targetPort: maybeOSCOutput.targetPort,
address: maybeOSCOutput.address,
args: maybeOSCOutput.args,
@@ -176,12 +157,33 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
assert.hasKeys(maybeHTTPOutput, ['url']);
assert.isString(maybeHTTPOutput.url);
try {
const url = new URL(replaceHTTPTemplatesForValidation(maybeHTTPOutput.url));
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
throw new Error('Invalid HTTP URL');
}
} catch {
throw new Error('Invalid HTTP URL');
}
return {
type: 'http',
url: maybeHTTPOutput.url,
};
}
/** Replaces runtime values so their surrounding host or URL syntax can be validated before execution. */
function replaceTemplatesForValidation(value: string, replacement: string): string {
return value.replace(/{{.*?}}/g, replacement);
}
function replaceHTTPTemplatesForValidation(value: string): string {
if (/^{{.*?}}$/.test(value)) {
return 'https://template.local';
}
return replaceTemplatesForValidation(value, 'template');
}
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
assert.hasKeys(maybeOntimeAction, ['action']);
assert.isString(maybeOntimeAction.action);
@@ -14,7 +14,8 @@ const udpClient = dgram.createSocket('udp4');
*/
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
const message = preparePayload(output, store);
emit(output.targetIP, output.targetPort, message);
const targetIP = parseTemplateNested(output.targetIP, store);
emit(targetIP, output.targetPort, message);
}
/** Parses the state and prepares payload to be emitted */
@@ -1718,16 +1718,18 @@ describe('rundownMutation.swap()', () => {
expect((testRundown.entries['1'] as OntimeEvent).id).toBe('1');
expect((testRundown.entries['1'] as OntimeEvent).cue).toBe('data2');
expect((testRundown.entries['1'] as OntimeEvent).timeStart).toBe(1);
expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(1);
// the swapped entries changed, their revision has to say so
expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(2);
expect((testRundown.entries['2'] as OntimeEvent).id).toBe('2');
expect((testRundown.entries['2'] as OntimeEvent).cue).toBe('data1');
expect((testRundown.entries['2'] as OntimeEvent).timeStart).toBe(2);
expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(1);
expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(2);
expect((testRundown.entries['3'] as OntimeEvent).id).toBe('3');
expect((testRundown.entries['3'] as OntimeEvent).cue).toBe('data3');
expect((testRundown.entries['3'] as OntimeEvent).timeStart).toBe(3);
// untouched, so it keeps its revision
expect((testRundown.entries['3'] as OntimeEvent).revision).toBe(1);
});
});
@@ -422,7 +422,7 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
gap: eventFrom.gap,
dayOffset: eventFrom.dayOffset,
// keep revision number but increment it
revision: eventFrom.revision++,
revision: eventFrom.revision + 1,
};
rundown.entries[eventTo.id] = {
@@ -440,7 +440,7 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
gap: eventTo.gap,
dayOffset: eventTo.dayOffset,
// keep revision number but increment it
revision: eventTo.revision++,
revision: eventTo.revision + 1,
};
}
@@ -816,27 +816,23 @@ export async function createNewRundown(title: string) {
* @throws if the provided id does not exist
*/
export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const { rundown, commit } = createTransaction({ rundownId: id, mutableRundown: true });
await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
/**
* If we are modifying the loaded rundown we re-init it
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (isCurrentRundown(id)) {
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
// init rundown does its own refetch
await initRundown(rundown, customField);
} else {
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
if (rundown.title === title) {
return getDataProvider().getProjectRundowns();
}
return dataProvider.getProjectRundowns();
rundown.title = title;
// a title has no bearing on the schedule, there is nothing to process and no runtime to notify
const { rundownMetadata, revision } = await commit(false);
setImmediate(() => {
notifyChanges(id, rundownMetadata, revision, { external: true });
sendRefetch(RefetchKey.ProjectRundowns);
});
return getDataProvider().getProjectRundowns();
}
/**
+4
View File
@@ -9,6 +9,10 @@ Load only for touched domains. Add only stable, recurring invariants; not one-of
- Preserve entry identity and supported types across patch, clone, group, ungroup, reorder.
- Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state.
- No caller-owned rundown mutation unless explicitly contracted.
- Revisions mark change: a rundown revision advances on every commit, an entry revision on every
change to that entry. Delays carry no revision. Optimistic client updates apply the same bump the
server does, so an unchanged entry keeps its identity and clients can compare revisions to tell
what moved on.
## Persistence, realtime, cache