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)}>