From 67b874fe048b613e0797ae632ce77036e6614b25 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 12 Sep 2026 16:48:32 +0200 Subject: [PATCH] feat(automation): define validated automation recipes Provide tested recipe definitions, lifecycle labels, and input validation for common automation setups. --- .../__tests__/automationRecipes.test.ts | 193 ++++++++++ .../__tests__/automationUtils.test.ts | 42 ++- .../automations-panel/automationRecipes.ts | 337 ++++++++++++++++++ .../automations-panel/automationUtils.ts | 91 +++-- 4 files changed, 640 insertions(+), 23 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts diff --git a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts new file mode 100644 index 000000000..292753d35 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts @@ -0,0 +1,193 @@ +import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types'; + +import { + automationRecipes, + defaultValues, + getAvailableRecipes, + recipeCategoryOrder, + validateRecipeValues, +} from '../automationRecipes'; +import { operators } from '../automationUtils'; + +/** + * Recipes are shipped as constants but created through the same endpoint as a hand written + * automation. These assertions stand in for the server side validation, so a recipe cannot + * silently rot into something that 400s when the user presses create. + */ +describe('automationRecipes', () => { + const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) })); + + it('has unique ids', () => { + const ids = automationRecipes.map(({ id }) => id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('only uses categories the picker knows how to render', () => { + for (const { recipe } of built) { + expect(recipeCategoryOrder).toContain(recipe.category); + } + }); + + it('binds every recipe to at least one valid lifecycle', () => { + for (const { recipe } of built) { + expect(recipe.triggers.length).toBeGreaterThan(0); + for (const cycle of recipe.triggers) { + expect(timerLifecycleValues).toContain(cycle); + } + } + }); + + it('builds a titled automation with something to send, from its own defaults', () => { + for (const { automation } of built) { + expect(automation.title).not.toBe(''); + expect(automation.outputs.length).toBeGreaterThan(0); + + for (const output of automation.outputs) { + expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true); + } + } + }); + + it('gives every choice parameter options, and a default that is one of them', () => { + const choices = automationRecipes.flatMap(({ params }) => params.filter(({ type }) => type === 'choice')); + expect(choices.filter(({ options }) => !options?.length)).toEqual([]); + expect(choices.filter(({ options, defaultValue }) => !options?.some((o) => o.value === defaultValue))).toEqual([]); + }); + + it('reads every parameter it declares', () => { + // a param the builder ignores is a field the user fills in for nothing, and a typo in + // either half would put the literal 'undefined' inside a URL + for (const { recipe } of built) { + for (const param of recipe.params) { + // a choice can only take one of its own options, so probe with the last one + if (param.type === 'choice') { + const last = param.options?.at(-1)?.value ?? ''; + expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last); + continue; + } + const marker = + param.type === 'number' ? '4242' : param.validation?.kind === 'url' ? 'http://ontime-probe' : 'ontime-probe'; + const probed = { ...defaultValues(recipe), [param.name]: marker }; + expect(JSON.stringify(recipe.build(probed))).toContain(marker); + } + } + }); + + it('only uses filter operators the server accepts', () => { + const allowed = operators.map(({ value }) => value); + for (const { automation } of built) { + for (const filter of automation.filters) { + expect(allowed).toContain(filter.operator); + } + } + }); + + it('defaults every external target to this machine', () => { + const outputs = built.flatMap(({ automation }) => automation.outputs); + const osc = outputs.filter(isOSCOutput); + const http = outputs.filter(isHTTPOutput); + + // filtering rather than asserting in a branch, so a failure names the offending recipe + expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]); + expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]); + expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]); + }); + + it('hides local-network recipes in Ontime Cloud', () => { + expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([ + 'ontime-aux-timer', + 'ontime-aux-stop', + 'ontime-warn-stage', + 'ontime-clear-message', + 'ontime-secondary-message', + 'webhook-event-title', + ]); + }); + + it('validates recipe addresses and numeric bounds', () => { + const qlab = automationRecipes.find(({ id }) => id === 'qlab-go'); + const companion = automationRecipes.find(({ id }) => id === 'companion-press'); + const vmix = automationRecipes.find(({ id }) => id === 'vmix-overlay-warning'); + const webhook = automationRecipes.find(({ id }) => id === 'webhook-event-title'); + + expect(qlab && validateRecipeValues(qlab, { ip: 'not a host', port: '70000' })).toEqual({ + ip: 'Enter an IP address or hostname', + port: 'Enter a whole number from 1024 to 65535', + }); + expect( + companion && validateRecipeValues(companion, { host: 'localhost:8888', page: '1.5', row: '-1', column: '0' }), + ).toEqual({ + host: 'Enter a URL starting with http:// or https://', + page: 'Enter a whole number of 1 or more', + row: 'Enter a whole number of 0 or more', + }); + expect(vmix && validateRecipeValues(vmix, { host: 'http://127.0.0.1:8088', overlay: '5' })).toEqual({ + overlay: 'Enter a whole number from 1 to 4', + }); + expect(webhook && validateRecipeValues(webhook, { url: 'ftp://example.com' })).toEqual({ + url: 'Enter a URL starting with http:// or https://', + }); + }); + + it('accepts every recipe default', () => { + for (const recipe of automationRecipes) { + expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({}); + } + }); + + /** the outputs a recipe builds from the given answers, as plain JSON to assert against */ + function buildWith(id: string, values: Record) { + const recipe = automationRecipes.find((candidate) => candidate.id === id); + return JSON.stringify(recipe?.build(values).outputs); + } + + it('tolerates a URL that already carries a query', () => { + expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain( + '/now?source=ontime&title=', + ); + }); + + it('marks the event title for URL-safe substitution', () => { + expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now' })).toContain( + 'title={{url:eventNow.title}}', + ); + }); + + it('adds webhook query parameters before a URL fragment', () => { + expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now#details' })).toContain( + '/now?title={{url:eventNow.title}}#details', + ); + }); + + it('rejects invalid aux timer durations', () => { + const auxTimer = automationRecipes.find(({ id }) => id === 'ontime-aux-timer'); + + expect(auxTimer && validateRecipeValues(auxTimer, { aux: '1', duration: 'abc' })).toEqual({ + duration: 'Enter a valid duration', + }); + }); + + it('rejects unsupported OSC targets', () => { + const qlab = automationRecipes.find(({ id }) => id === 'qlab-go'); + + expect(qlab && validateRecipeValues(qlab, { ip: '::1', port: '53000' })).toEqual({ + ip: 'Enter an IP address or hostname', + }); + }); + + it('keeps recipe previews available while an address is invalid', () => { + expect(() => buildWith('companion-press', { host: 'http:', page: '1', row: '0', column: '0' })).not.toThrow(); + }); + + it('tolerates an address pasted with a trailing slash', () => { + expect( + buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }), + ).toContain('http://127.0.0.1:8888/api/location/1/0/0/press'); + }); + + it('drops a pasted query before adding a Companion path', () => { + expect( + buildWith('companion-press', { host: 'http://127.0.0.1:8888?token=value', page: '1', row: '0', column: '0' }), + ).toContain('http://127.0.0.1:8888/api/location/1/0/0/press'); + }); +}); 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/automationRecipes.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts new file mode 100644 index 000000000..ba519be81 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts @@ -0,0 +1,337 @@ +import type { AutomationDTO, AutomationOutput, TimerLifeCycle } from 'ontime-types'; +import { TimerLifeCycle as Cycle } from 'ontime-types'; +import { parseUserTime } from 'ontime-utils'; + +export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging'; + +export const recipeCategoryLabels: Record = { + ontime: 'Ontime automations', + playback: 'Playback and cue systems', + video: 'Video and streaming', + messaging: 'Webhooks and messaging', +}; + +export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging']; + +export type RecipeParam = { + name: string; + label: string; + hint?: string; + type?: 'text' | 'number' | 'choice'; + options?: { value: string; label: string }[]; + wide?: boolean; + defaultValue: string; + validation?: + | { kind: 'duration' } + | { kind: 'host' } + | { kind: 'url' } + | { kind: 'integer'; min: number; max?: number }; +}; + +export type RecipeValues = Record; + +export type AutomationRecipe = { + id: string; + title: string; + description: string; + category: RecipeCategory; + localOnly?: boolean; + keywords?: string[]; + params: RecipeParam[]; + triggers: TimerLifeCycle[]; + build: (values: RecipeValues) => AutomationDTO; +}; + +const auxTimers = [ + { value: '1', label: 'Aux timer 1' }, + { value: '2', label: 'Aux timer 2' }, + { value: '3', label: 'Aux timer 3' }, +]; + +type AuxNumber = '1' | '2' | '3'; + +function toAux(value: string): AuxNumber { + return value === '2' || value === '3' ? value : '1'; +} + +const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const; +const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const; +const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const; +const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const; + +function origin(value: string): string { + try { + return new URL(value.trim()).origin; + } catch { + return value.trim(); + } +} + +function withQuery(url: string, query: string): string { + const trimmed = url.trim(); + const fragmentIndex = trimmed.indexOf('#'); + const base = fragmentIndex === -1 ? trimmed : trimmed.slice(0, fragmentIndex); + const fragment = fragmentIndex === -1 ? '' : trimmed.slice(fragmentIndex); + return `${base}${base.includes('?') ? '&' : '?'}${query}${fragment}`; +} + +function buildUnfilteredAutomation(title: string, outputs: AutomationOutput[]): AutomationDTO { + return { title, filterRule: 'all', filters: [], outputs }; +} + +export const automationRecipes: AutomationRecipe[] = [ + { + id: 'ontime-aux-timer', + title: 'Run an aux timer with the event', + description: 'Sets an aux timer and starts it whenever an event starts.', + category: 'ontime', + keywords: ['countdown', 'stage timer', 'speaker'], + params: [ + { name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }, + { + name: 'duration', + label: 'Duration', + hint: 'hh:mm:ss', + defaultValue: '00:05:00', + validation: { kind: 'duration' }, + }, + ], + triggers: [Cycle.onStart], + build: ({ aux, duration }) => + buildUnfilteredAutomation(`Run Aux Timer ${toAux(aux)} with the event`, [ + { type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() }, + { type: 'ontime', action: auxStart[toAux(aux)] }, + ]), + }, + { + id: 'ontime-aux-stop', + title: 'Stop the aux timer when the event ends', + description: 'Stops an aux timer on finish, so it does not keep running into the next event.', + category: 'ontime', + keywords: ['countdown', 'stage timer', 'reset'], + params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }], + triggers: [Cycle.onFinish], + build: ({ aux }) => + buildUnfilteredAutomation(`Stop Aux Timer ${toAux(aux)} on finish`, [ + { type: 'ontime', action: auxStop[toAux(aux)] }, + ]), + }, + { + id: 'ontime-warn-stage', + title: 'Warn the stage when time runs low', + description: 'Shows a message on the stage timer as the running event enters its danger window.', + category: 'ontime', + keywords: ['message', 'danger', 'wrap up', 'presenter'], + params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }], + triggers: [Cycle.onDanger], + build: ({ message }) => + buildUnfilteredAutomation('Warn the stage at danger', [ + { type: 'ontime', action: 'message-set', text: message, visible: true }, + ]), + }, + { + id: 'ontime-clear-message', + title: 'Hide the stage message on finish', + description: 'Clears the stage message once the event finishes. Pairs with the warning above.', + category: 'ontime', + keywords: ['message', 'clear', 'presenter'], + params: [], + triggers: [Cycle.onFinish], + build: () => + buildUnfilteredAutomation('Hide the stage message on finish', [ + { type: 'ontime', action: 'message-set', text: '', visible: false }, + ]), + }, + { + id: 'ontime-secondary-message', + title: 'Show an aux timer beside the stage message', + description: 'Points the secondary field on the stage timer at an aux timer when an event loads.', + category: 'ontime', + keywords: ['message', 'secondary', 'stage', 'countdown'], + params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }], + triggers: [Cycle.onLoad], + build: ({ aux }) => + buildUnfilteredAutomation(`Show Aux Timer ${toAux(aux)} as the secondary message`, [ + { type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] }, + ]), + }, + { + id: 'qlab-go', + title: 'QLab — fire the matching cue', + description: "Starts the QLab cue whose number matches the Ontime event's cue.", + category: 'playback', + localOnly: true, + keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'], + params: [ + { + name: 'ip', + label: 'QLab computer', + hint: 'IP address of the machine running QLab', + wide: true, + defaultValue: '127.0.0.1', + validation: { kind: 'host' }, + }, + { + name: 'port', + label: 'OSC port', + type: 'number', + hint: "QLab's default is 53000", + defaultValue: '53000', + validation: { kind: 'integer', min: 1024, max: 65535 }, + }, + ], + triggers: [Cycle.onStart], + build: ({ ip, port }) => + buildUnfilteredAutomation('QLab GO on event start', [ + { + type: 'osc', + targetIP: ip.trim(), + targetPort: Number(port), + address: '/cue/{{eventNow.cue}}/start', + args: '', + }, + ]), + }, + { + id: 'companion-press', + title: 'Companion — press a button', + description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.', + category: 'playback', + localOnly: true, + keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'], + params: [ + { + name: 'host', + label: 'Companion address', + hint: 'Where the Companion HTTP API is listening', + wide: true, + defaultValue: 'http://127.0.0.1:8888', + validation: { kind: 'url' }, + }, + { name: 'page', label: 'Page', type: 'number', defaultValue: '1', validation: { kind: 'integer', min: 1 } }, + { name: 'row', label: 'Row', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } }, + { name: 'column', label: 'Column', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } }, + ], + triggers: [Cycle.onStart], + build: ({ host, page, row, column }) => + buildUnfilteredAutomation('Companion button press', [ + { type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` }, + ]), + }, + { + id: 'vmix-overlay-warning', + title: 'vMix — show an overlay on warning', + description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.', + category: 'video', + localOnly: true, + keywords: ['streaming', 'http', 'lower third', 'graphics'], + params: [ + { + name: 'host', + label: 'vMix address', + hint: 'The vMix web controller', + wide: true, + defaultValue: 'http://127.0.0.1:8088', + validation: { kind: 'url' }, + }, + { + name: 'overlay', + label: 'Overlay number', + type: 'number', + defaultValue: '1', + validation: { kind: 'integer', min: 1, max: 4 }, + }, + ], + triggers: [Cycle.onWarning], + build: ({ host, overlay }) => + buildUnfilteredAutomation('vMix overlay on warning', [ + { type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` }, + ]), + }, + { + id: 'webhook-event-title', + title: 'Webhook — send the running event title', + description: 'Calls any URL with the running event title, as a template string you can edit afterwards.', + category: 'messaging', + keywords: ['http', 'rest', 'api', 'integration', 'slack'], + params: [ + { + name: 'url', + label: 'URL', + hint: 'The event title is added as a title parameter', + wide: true, + defaultValue: 'http://127.0.0.1:3000/now', + validation: { kind: 'url' }, + }, + ], + triggers: [Cycle.onStart], + build: ({ url }) => + buildUnfilteredAutomation('Webhook with the current event', [ + { type: 'http', url: withQuery(url, 'title={{url:eventNow.title}}') }, + ]), + }, +]; + +export function defaultValues(recipe: AutomationRecipe): RecipeValues { + return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue])); +} + +export function getAvailableRecipes(isCloud: boolean): AutomationRecipe[] { + return isCloud ? automationRecipes.filter((recipe) => !recipe.localOnly) : automationRecipes; +} + +export function validateRecipeValues(recipe: AutomationRecipe, values: RecipeValues): Record { + const errors: Record = {}; + + for (const param of recipe.params) { + const value = values[param.name]?.trim() ?? ''; + if (!value) { + errors[param.name] = 'Required field'; + continue; + } + + if (param.validation?.kind === 'url' && !isHttpUrl(value)) { + errors[param.name] = 'Enter a URL starting with http:// or https://'; + } else if (param.validation?.kind === 'duration' && !isDuration(value)) { + errors[param.name] = 'Enter a valid duration'; + } else if (param.validation?.kind === 'host' && !isHost(value)) { + errors[param.name] = 'Enter an IP address or hostname'; + } else if (param.validation?.kind === 'integer') { + const number = Number(value); + const { min, max } = param.validation; + if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) { + errors[param.name] = + max === undefined ? `Enter a whole number of ${min} or more` : `Enter a whole number from ${min} to ${max}`; + } + } + } + + return errors; +} + +function isDuration(value: string): boolean { + return /^\d+(?::\d{1,2}){0,2}$/.test(value) && (parseUserTime(value) > 0 || /^0+(?::0+){0,2}$/.test(value)); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname); + } catch { + return false; + } +} + +function isHost(value: string): boolean { + if (value === 'localhost') { + return true; + } + + const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/; + const hostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i; + if (ipv4.test(value) || hostname.test(value)) { + return true; + } + + return false; +} 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..f47ffb98f 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,21 +1,50 @@ -import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types'; +import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types'; + +import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle'; + +/** + * Names a trigger created from an automation's lifecycle picker. + * Shared so a trigger made by the form and one made by a recipe read the same in the list. + */ +export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string { + return `${automationTitle} — ${getLifecycleLabel(cycle)}`; +} + +/** + * Outputs are a union, so react-hook-form cannot resolve a field's error by name. + * Every output card knows which fields it registered, this just makes them reachable. + */ +export type OutputErrors = Partial>; 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 }, +]; + +/** + * Filter operators offered in the automation form + * NOTE: not_contains is supported by the type and by the runtime, but the server + * validation list omits it, so an automation using it cannot be saved. + * It stays out of the UI until the server accepts it. + */ +export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [ + { value: 'equals', label: 'equals' }, + { value: 'not_equals', label: 'does not equal' }, + { value: 'contains', label: 'contains' }, + { value: 'greater_than', label: 'is greater than' }, + { value: 'less_than', label: 'is less than' }, ]; /** @@ -66,20 +95,38 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] { * We warn the user if they have created multiple links between the same automation and a trigger */ export function checkDuplicates(triggers: Trigger[]) { - const triggersMap: Record = {}; - const duplicates = []; + const seen = new Set(); + 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; } + +/** + * 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; +}