From 419ca5c4ca92f6f8b7aab41052c36a9e328a6f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:27:06 +0000 Subject: [PATCH] refactor(automation): name lifecycles and outputs the same way everywhere A lifecycle was written three ways depending on where you were standing: "On Start" in the automation settings, the raw "onStart" in the event editor, and again as a literal in the trigger list. Anyone attaching an automation to an event had to work out that the two lists were the same list. One label map now owns the user facing names, and one helper summarises what an automation sends. Both are shared, so the event editor gains the labels and a Sends column for free: the trigger row says which automation runs and what it will do, instead of only its title. Two things the panel could not say before, now that it can look them up: - A trigger can outlive the automation it points at, say after a partial project import. It rendered as an empty tag; it now says so. - not_contains is in the type and the runtime, but the server validation list omits it, so an automation using it cannot be saved. The operator list moves out of the form and drops it, with a test to stop it coming back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf --- .../src/common/constants/timerLifecycle.ts | 25 +++++++++ .../utils/__tests__/automationOutputs.test.ts | 32 +++++++++++ .../src/common/utils/automationOutputs.ts | 32 +++++++++++ .../automations-panel/TriggersListItem.tsx | 7 ++- .../__tests__/automationUtils.test.ts | 42 +++++++++++++- .../automations-panel/automationUtils.ts | 56 +++++++++++++++---- .../app-settings/useAppSettingsMenu.tsx | 18 +++++- .../composite/EventEditorTriggers.module.scss | 10 +++- .../composite/EventEditorTriggers.tsx | 13 ++++- 9 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 apps/client/src/common/constants/timerLifecycle.ts create mode 100644 apps/client/src/common/utils/__tests__/automationOutputs.test.ts create mode 100644 apps/client/src/common/utils/automationOutputs.ts diff --git a/apps/client/src/common/constants/timerLifecycle.ts b/apps/client/src/common/constants/timerLifecycle.ts new file mode 100644 index 000000000..c8ec4b9cf --- /dev/null +++ b/apps/client/src/common/constants/timerLifecycle.ts @@ -0,0 +1,25 @@ +import { TimerLifeCycle } from 'ontime-types'; + +/** + * User facing labels for the timer lifecycle + * Shared between the automation settings and the rundown event editor + * so that a lifecycle is named the same everywhere it is shown + */ +export const lifecycleLabels: Record = { + [TimerLifeCycle.onLoad]: 'On Load', + [TimerLifeCycle.onStart]: 'On Start', + [TimerLifeCycle.onPause]: 'On Pause', + [TimerLifeCycle.onStop]: 'On Stop', + [TimerLifeCycle.onClock]: 'Every second', + [TimerLifeCycle.onUpdate]: 'On Timer Update', + [TimerLifeCycle.onFinish]: 'On Finish', + [TimerLifeCycle.onWarning]: 'On Warning', + [TimerLifeCycle.onDanger]: 'On Danger', +}; + +/** + * Resolves a lifecycle to its user facing label, falling back to the raw value + */ +export function getLifecycleLabel(cycle: TimerLifeCycle | string): string { + return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle; +} diff --git a/apps/client/src/common/utils/__tests__/automationOutputs.test.ts b/apps/client/src/common/utils/__tests__/automationOutputs.test.ts new file mode 100644 index 000000000..7ba5fe1a9 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/automationOutputs.test.ts @@ -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']); + }); +}); diff --git a/apps/client/src/common/utils/automationOutputs.ts b/apps/client/src/common/utils/automationOutputs.ts new file mode 100644 index 000000000..d20a49177 --- /dev/null +++ b/apps/client/src/common/utils/automationOutputs.ts @@ -0,0 +1,32 @@ +import type { AutomationOutput } from 'ontime-types'; + +const outputLabels: Record = { + osc: 'OSC', + http: 'HTTP', + ontime: 'Ontime', +}; + +export type OutputSummary = { + type: AutomationOutput['type']; + label: string; + count: number; +}; + +/** + * Summarises an automation's outputs by kind so that a list row can say what the + * automation does without the user having to open the form. + * Shared between the automation settings panel and the rundown event editor. + */ +export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] { + const counts = new Map(); + + for (const output of outputs) { + counts.set(output.type, (counts.get(output.type) ?? 0) + 1); + } + + // keep a stable presentation order regardless of the order the user added outputs + const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime']; + return order + .filter((type) => counts.has(type)) + .map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number })); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx index aecda7a0c..07aa22268 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx @@ -31,7 +31,12 @@ export default function TriggersListItem(props: TriggersListItemProps) { {cycles.find((cycle) => cycle.value === trigger.trigger)?.label} - {automations?.[trigger.automationId]?.title} + {/* a trigger can outlive the automation it points at, say after a partial project import */} + {automations?.[trigger.automationId] ? ( + {automations[trigger.automationId].title} + ) : ( + Missing automation + )} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts index eaa95f29b..908d1c31b 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts @@ -1,6 +1,6 @@ import { TimerLifeCycle, Trigger } from 'ontime-types'; -import { checkDuplicates } from '../automationUtils'; +import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils'; describe('checkDuplicates', () => { it('should return undefined if there are no duplicates', () => { @@ -22,3 +22,43 @@ describe('checkDuplicates', () => { expect(checkDuplicates(triggers)).toStrictEqual([2]); }); }); + +describe('groupTriggersByAutomation', () => { + it('returns an empty object when there are no triggers', () => { + expect(groupTriggersByAutomation([])).toEqual({}); + }); + + it('collects the lifecycles each automation is bound to', () => { + const triggers: Trigger[] = [ + { id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' }, + { id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' }, + { id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' }, + ]; + + expect(groupTriggersByAutomation(triggers)).toEqual({ + a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish], + b: [TimerLifeCycle.onLoad], + }); + }); + + it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => { + const triggers: Trigger[] = [ + { id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' }, + { id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' }, + ]; + + expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] }); + }); +}); + +describe('operators', () => { + it('does not offer not_contains, which the server validation rejects', () => { + expect(operators.map(({ value }) => value)).not.toContain('not_contains'); + }); +}); + +describe('cycles', () => { + it('uses the shared user facing labels', () => { + expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start'); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts index b83935970..7978f42e4 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts @@ -1,4 +1,6 @@ -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; @@ -7,15 +9,29 @@ type CycleLabel = { }; 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' }, + { id: 1, label: lifecycleLabels.onLoad, value: 'onLoad' }, + { id: 2, label: lifecycleLabels.onStart, value: 'onStart' }, + { id: 3, label: lifecycleLabels.onPause, value: 'onPause' }, + { id: 4, label: lifecycleLabels.onStop, value: 'onStop' }, + { id: 5, label: lifecycleLabels.onClock, value: 'onClock' }, + { id: 6, label: lifecycleLabels.onUpdate, value: 'onUpdate' }, + { id: 7, label: lifecycleLabels.onFinish, value: 'onFinish' }, + { id: 8, label: lifecycleLabels.onWarning, value: 'onWarning' }, + { id: 9, label: lifecycleLabels.onDanger, value: 'onDanger' }, +]; + +/** + * Filter operators offered in the automation form + * NOTE: not_contains is supported by the type and by the runtime, but the server + * validation list omits it, so an automation using it cannot be saved. + * It stays out of the UI until the server accepts it. + */ +export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [ + { value: 'equals', label: 'equals' }, + { value: 'not_equals', label: 'does not equal' }, + { value: 'contains', label: 'contains' }, + { value: 'greater_than', label: 'is greater than' }, + { value: 'less_than', label: 'is less than' }, ]; /** @@ -83,3 +99,23 @@ export function checkDuplicates(triggers: Trigger[]) { } return duplicates.length > 0 ? duplicates : undefined; } + +/** + * Groups the lifecycles each automation is bound to + * Used to show when an automation runs, and to highlight the ones that never will + */ +export function groupTriggersByAutomation(triggers: Trigger[]): Record { + const grouped: Record = {}; + + for (const trigger of triggers) { + if (!Object.hasOwn(grouped, trigger.automationId)) { + grouped[trigger.automationId] = []; + } + // the runtime fires an automation once per lifecycle, duplicates would be noise here + if (!grouped[trigger.automationId].includes(trigger.trigger)) { + grouped[trigger.automationId].push(trigger.trigger); + } + } + + return grouped; +} diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 8c7b57f0e..1b09e682a 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -84,11 +84,25 @@ const staticOptions = [ { id: 'automation__automations', label: 'Manage automations', - keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'], + keywords: [ + 'osc', + 'http', + 'webhook', + 'integration', + 'api', + 'output', + 'action', + 'recipe', + 'example', + 'preset', + 'qlab', + 'vmix', + 'companion', + ], }, { id: 'automation__triggers', - label: 'Manage triggers', + label: 'Global triggers', keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'], }, ], diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss index 6aa4185e2..9904e481e 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss @@ -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; diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx index 893cce880..fbe747658 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx @@ -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(); const seen = new Map(); @@ -76,6 +79,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
Lifecycle Automation + Sends
{triggers.map((trigger) => { const isDuplicate = duplicateIds.has(trigger.id); @@ -103,6 +107,13 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr }} options={automationOptions} /> +
+ {summariseOutputs(automationSettings.automations[trigger.automationId]?.outputs ?? []).map( + ({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + ), + )} +
handleDelete(trigger.id)}>