From ab61d3434bf958636b446af2fa123323e1aeb1a7 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 11 Sep 2026 19:56:56 +0200 Subject: [PATCH] feat(automation): label event triggers and outputs clearly --- .../src/common/constants/timerLifecycle.ts | 25 +++++++++++++++ .../utils/__tests__/automationOutputs.test.ts | 32 +++++++++++++++++++ .../src/common/utils/automationOutputs.ts | 32 +++++++++++++++++++ .../composite/EventEditorTriggers.module.scss | 10 ++++-- .../composite/EventEditorTriggers.tsx | 13 +++++++- 5 files changed, 109 insertions(+), 3 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/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)}>