From 0f9e4365769b7dd7f001d2a323f9913115b1b579 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 10:53:27 +0000 Subject: [PATCH] refactor(automation): make the automations panel legible at a glance The list showed counts rather than meaning, and one column was mislabelled "Trigger rule" while rendering the filter rule. The form buried the test and delete actions inside four fixed grids, and the Test buttons discarded their result entirely, so a failing output looked identical to a working one. - list rows now say when an automation runs, what it sends, and flag the two silent misconfigurations: an automation with no triggers and one with no outputs - outputs and filters render as cards with a shared header, replacing the fixed grids and the   spacer hack used to fake a label-height cell - test results are reported inline, keyed by field array id so removing an output cannot leave feedback on the wrong row - filters gain a plain language summary, and the filter rule only shows when there is more than one filter to combine - lifecycle labels are shared with the rundown event editor, which was showing raw enum values, and it now shows what the linked automation sends - triggers pointing at a deleted automation say so instead of rendering an empty tag; the duplicates warning describes what actually happens not_contains stays out of the operator list: the type and the runtime support it but the server validation list omits it, so it cannot be saved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H --- .../src/common/constants/timerLifecycle.ts | 25 + .../utils/__tests__/automationOutputs.test.ts | 32 + .../src/common/utils/automationOutputs.ts | 32 + .../AutomationForm.module.scss | 113 ++-- .../automations-panel/AutomationForm.tsx | 555 ++++++++++-------- .../automations-panel/AutomationPanel.tsx | 7 +- .../AutomationSettingsForm.tsx | 3 +- .../AutomationsList.module.scss | 3 + .../automations-panel/AutomationsList.tsx | 62 +- .../automations-panel/OntimeActionForm.tsx | 15 +- .../panel/automations-panel/TriggersList.tsx | 23 +- .../automations-panel/TriggersListItem.tsx | 7 +- .../__tests__/automationUtils.test.ts | 42 +- .../automations-panel/automationUtils.ts | 56 +- .../app-settings/useAppSettingsMenu.tsx | 19 +- .../composite/EventEditorTriggers.module.scss | 10 +- .../composite/EventEditorTriggers.tsx | 13 +- 17 files changed, 692 insertions(+), 325 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 create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss 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/AutomationForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss index 0567f45ea..d258c04b3 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss @@ -26,61 +26,84 @@ gap: 1rem; } -.titleSection, -.filterSection, -.oscSection, -.httpSection, -.actionSection { +.titleSection { display: grid; + grid-template-columns: 1fr; grid-gap: 0.5rem; - - button { - align-self: flex-end; - } } .titleSection, .ruleSection, -.filterSection, -.oscSection, -.httpSection, -.actionSection { - label, - div { - // we use the div as non-interactive placeholder for button cells - // it needs to match the size of the label element - font-size: calc(1rem - 3px); - } +.card { label { + display: block; + font-size: calc(1rem - 3px); color: $label-gray; } } -.titleSection { - grid-template-columns: 1fr; -} - -.filterSection { - grid-template-columns: 2fr 1fr 2fr auto; -} - -.oscSection { - grid-template-columns: 9rem 5rem 3fr 4fr auto; -} - -.httpSection { - grid-template-columns: 1fr auto; -} - -.actionSection { - grid-template-columns: auto 1fr 1fr auto; - - .test { - grid-column: -1; - } -} - -.outputCard { +/** shared shell for a single filter or output */ +.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; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid $white-10; +} + +/** pushes the actions to the end of the header, and absorbs any overflow */ +.cardSummary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: $aux-text-size; + color: $secondary-text-gray; +} + +.cardBody { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.5rem 0.75rem; + padding: 0.75rem; +} + +/** for fields that read badly when narrow: OSC address and args, URLs, message text */ +.spanFull { + grid-column: 1 / -1; +} + +.testOk { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: $aux-text-size; + color: $green-400; +} + +.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; } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx index b5966877c..a0496c4e8 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -1,6 +1,7 @@ import { Automation, AutomationDTO, + AutomationFilter, HTTPOutput, OSCOutput, OntimeAction, @@ -8,14 +9,15 @@ import { isOSCOutput, isOntimeAction, } from 'ontime-types'; -import { useEffect, useMemo } from 'react'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; -import { IoAdd, IoTrash } from 'react-icons/io5'; +import { IoAdd, IoCheckmark, IoTrash } from 'react-icons/io5'; import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; 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'; @@ -26,8 +28,9 @@ 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 { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../../panel-utils/PanelUtils'; -import { isAutomation, makeFieldList } from './automationUtils'; +import { isAutomation, makeFieldList, operators } from './automationUtils'; import OntimeActionForm from './OntimeActionForm'; import TemplateInput from './template-input/TemplateInput'; @@ -36,6 +39,11 @@ import style from './AutomationForm.module.scss'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const formId = 'automation-form'; +/** how long a successful test keeps its confirmation on screen */ +const testFeedbackDuration = 2000; + +type TestState = { status: 'sending' | 'ok' | 'error'; message?: string }; + interface AutomationFormProps { automation: Automation | AutomationDTO; onClose: () => void; @@ -47,6 +55,13 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr const { refetch } = useAutomationSettings(); const fieldList = useMemo(() => makeFieldList(data), [data]); + /** + * Test results are keyed by the field array id rather than the index: + * removing an output shifts every index after it, which would leave feedback on the wrong row + */ + const [testResults, setTestResults] = useState>({}); + const feedbackTimers = useRef>>({}); + const { control, handleSubmit, @@ -93,6 +108,26 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr setFocus('title'); }, [setFocus]); + // the timers outlive a fast close, clearing them avoids setting state on an unmounted form + 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 handleAddNewFilter = () => { appendFilter({ field: '', operator: 'equals', value: '' }); }; @@ -110,12 +145,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr appendOutput({ type: 'ontime', action: 'aux1-start' }); }; - const handleTestOSCOutput = async (index: number) => { + const handleTestOSCOutput = async (index: number, key: string) => { + const values = getValues(`outputs.${index}`) as OSCOutput; + if (!values.targetIP || !values.targetPort || !values.address) { + reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' }); + return; + } + + reportTest(key, { status: 'sending' }); try { - const values = getValues(`outputs.${index}`) as OSCOutput; - if (!values.targetIP || !values.targetPort || !values.address) { - return; - } await testOutput({ type: 'osc', targetIP: values.targetIP, @@ -123,36 +161,39 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr address: values.address, args: values.args, }); - } catch (_error) { - /** we dont handle errors here, users should use the network tab */ + // OSC is fire and forget over UDP, the most we can honestly claim is that we sent it + reportTest(key, { status: 'ok', message: 'Sent' }); + } catch (error) { + reportTest(key, { status: 'error', message: maybeAxiosError(error) }); } }; - const handleTestHTTPOutput = async (index: number) => { + const handleTestHTTPOutput = async (index: number, key: string) => { + const values = getValues(`outputs.${index}`) as HTTPOutput; + if (!values.url) { + reportTest(key, { status: 'error', message: 'Add a target URL before testing' }); + return; + } + + reportTest(key, { status: 'sending' }); try { - const values = getValues(`outputs.${index}`) as HTTPOutput; - if (!values.url) { - return; - } - await testOutput({ - type: 'http', - url: values.url, - }); - } catch (_error) { - /** we dont handle errors here, users should use the network tab */ + await testOutput({ type: 'http', url: values.url }); + reportTest(key, { status: 'ok', message: 'Sent' }); + } catch (error) { + reportTest(key, { status: 'error', message: maybeAxiosError(error) }); } }; - const handleTestOntimeAction = async (index: number) => { + const handleTestOntimeAction = async (index: number, key: string) => { + const values = getValues(`outputs.${index}`) as OntimeAction; + + 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, type: 'ontime' }); + reportTest(key, { status: 'ok', message: 'Done' }); + } catch (error) { + reportTest(key, { status: 'error', message: maybeAxiosError(error) }); } }; @@ -183,6 +224,21 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } }; + /** describes a filter in plain language so the user does not have to read the form back to themselves */ + const describeFilter = (index: number): string | null => { + const field = watch(`filters.${index}.field`); + if (!field) { + return null; + } + + const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field; + const operator = watch(`filters.${index}.operator`); + const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator; + const value = watch(`filters.${index}.value`); + + return `${fieldLabel} ${operatorLabel} ${value ? `“${value}”` : 'nothing'}`; + }; + const canSubmit = !isSubmitting && isDirty && isValid; return ( @@ -191,6 +247,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr onClose={onClose} showBackdrop showCloseButton + size='wide' title={isEdit ? 'Edit automation' : 'Create automation'} bodyElements={
@@ -211,83 +268,77 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr

Filters (optional)

+ + Without filters the outputs are sent every time the automation is triggered. +
- + {fieldFilters.length > 1 && ( + + )} {fieldFilters.map((field, index) => { - const key = `filters.${index}.field.${field.id}`; + const description = describeFilter(index); return ( -
- - -
-   -
- removeFilter(index)} - > - - -
+
+
+ Filter + {description} + removeFilter(index)} + > + + +
+
+ +
); @@ -309,6 +360,13 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr read the docs + {fieldOutputs.length === 0 && ( + + )} + {fieldOutputs.map((output, index) => { if (isOSCOutput(output)) { const rowErrors = errors.outputs?.[index] as @@ -321,75 +379,66 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr | undefined; return ( -
- OSC -
- - - - -
-   - - - removeOutput(index)} - > - - - -
-
-
+ handleTestOSCOutput(index, output.id)} + onDelete={() => removeOutput(index)} + > + + + + + ); } + if (isHTTPOutput(output)) { const rowErrors = errors.outputs?.[index] as | { @@ -397,42 +446,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } | undefined; return ( -
- HTTP -
- -
-   - - - removeOutput(index)} - > - - - -
-
-
+ handleTestHTTPOutput(index, output.id)} + onDelete={() => removeOutput(index)} + > + + ); } @@ -447,8 +485,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } | undefined; return ( -
- Ontime action + handleTestOntimeAction(index, output.id)} + onDelete={() => removeOutput(index)} + > -   - - - removeOutput(index)} - > - - - - -
+ /> + ); } return null; })} - - - - - +
+ } + items={[ + { + type: 'item', + label: 'OSC', + description: 'Send an OSC message to a device on the network', + onClick: handleAddNewOSCOutput, + }, + { + type: 'item', + label: 'HTTP', + description: 'Call a URL, for webhooks and REST APIs', + onClick: handleAddNewHTTPOutput, + }, + { + type: 'item', + label: 'Ontime action', + description: 'Change something inside Ontime, like a message or an aux timer', + onClick: handleAddnewOntimeAction, + }, + ]} + > + Add output + +
} @@ -503,3 +549,42 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr /> ); } + +interface OutputCardProps { + label: string; + kindClass?: string; + summary?: string; + testState?: TestState; + onTest: () => void; + onDelete: () => void; + children: ReactNode; +} + +/** + * Shared chrome for every output kind: the type tag and the actions live in the header, + * so they stop competing with the form fields for grid columns + */ +function OutputCard({ label, kindClass, summary, testState, onTest, onDelete, children }: OutputCardProps) { + return ( +
+
+ {label} + {summary} + {testState?.status === 'ok' && ( + + + {testState.message} + + )} + + + + +
+ {testState?.status === 'error' && {testState.message}} +
{children}
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx index 59a829b8d..ca52375f9 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx @@ -30,7 +30,12 @@ export default function AutomationPanel({ location }: PanelBaseProps) { />
- +
Control Ontime and share its data with external systems in your workflow. - - Automations allow Ontime to send its data on lifecycle triggers. + - An automation is what to send: OSC and HTTP messages, or an action inside Ontime. + - A trigger is when to send it. Triggers for a single event live in the event editor. - OSC Input tells Ontime to listen to messages on the specific port. See the docs diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss new file mode 100644 index 000000000..3137d0529 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss @@ -0,0 +1,3 @@ +.muted { + color: $muted-gray; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index a80935057..ad7b6a8b9 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -1,5 +1,5 @@ -import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; -import { Fragment, useState } from 'react'; +import { AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; +import { Fragment, useMemo, useState } from 'react'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { deleteAutomation } from '../../../../common/api/automation'; @@ -8,9 +8,14 @@ import Button from '../../../../common/components/buttons/Button'; import IconButton from '../../../../common/components/buttons/IconButton'; import Info from '../../../../common/components/info/Info'; import Tag from '../../../../common/components/tag/Tag'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; 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 { groupTriggersByAutomation } from './automationUtils'; + +import style from './AutomationsList.module.scss'; const automationPlaceholder: AutomationDTO = { title: '', @@ -21,11 +26,12 @@ const automationPlaceholder: AutomationDTO = { interface AutomationsListProps { automations: NormalisedAutomation; + triggers: Trigger[]; enabledAutomations?: boolean; isLoading: boolean; } -export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) { +export default function AutomationsList({ automations, triggers, enabledAutomations, isLoading }: AutomationsListProps) { const { refetch } = useAutomationSettings(); const [automationFormData, setAutomationFormData] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -41,6 +47,8 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa } }; + const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); + const arrayAutomations = Object.keys(automations); return ( @@ -69,10 +77,10 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa - Title - Trigger rule - Filters - Outputs + Title + Runs on + Filter rule + Sends @@ -82,9 +90,11 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa title='No automations yet' description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.' action={ - + + + } /> )} @@ -92,20 +102,42 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa if (!Object.hasOwn(automations, automationId)) { return null; } + const automation = automations[automationId]; + const lifecycles = lifecyclesByAutomation[automationId] ?? []; + const outputs = summariseOutputs(automation.outputs); + return ( - {automations[automationId].title} + {automation.title} + + {lifecycles.length === 0 ? ( + Never runs + ) : ( + lifecycles.map((cycle) => {getLifecycleLabel(cycle)}) + )} + - {automations[automationId].filterRule} + {automation.filters.length === 0 ? ( + + ) : ( + {automation.filterRule === 'all' ? 'All filters' : 'Any filter'} + )} - {automations[automationId].filters.length} - {automations[automationId].outputs.length} + + {outputs.length === 0 ? ( + No outputs + ) : ( + outputs.map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + )) + )} + setAutomationFormData(automations[automationId])} + onClick={() => setAutomationFormData(automation)} > diff --git a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx index 11f6413b4..e2cd556ec 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx @@ -1,5 +1,5 @@ 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'; @@ -30,9 +30,8 @@ export default function OntimeActionForm({ setValue, rowErrors, value, - children, watch, -}: PropsWithChildren) { +}: OntimeActionFormProps) { const [selectedAction, setSelectedAction] = useState(value); const handleSetAction = (value: OntimeActionKey) => { @@ -41,7 +40,7 @@ export default function OntimeActionForm({ }; return ( -
+ <>