From a905bf912f6eb2c0fc0acf931d30e93e710c1f03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:28:10 +0000 Subject: [PATCH] feat(automation): one step creation, recipes, and a panel that says what it does Making an automation took two visits to two lists. You wrote the automation in one, then remembered that an automation alone never runs, went to the trigger list, made a trigger, and pointed it back at what you had just made. A first time user who stopped after the first step got a thing that looked finished and did nothing. Lifecycles are now picked on the automation form, as chips. Saving reconciles the triggers behind it, diffed against a snapshot taken when the form opened so a save never removes a trigger the user could not see. Every lifecycle is in one place, including the two that fire continuously, which say so before you pick them rather than after your log fills up. That leaves the trigger list as what it actually is: a place to rename a generated trigger, or to point several differently named ones at the same automation. It says so, and it names the triggers whose automation is gone. New opens one list of starting points: an empty automation, then a handful of recipes for the software people actually pair Ontime with. A recipe is nothing but a pre-filled form, so choosing one opens the ordinary automation form with its values in place. The user reads what it will do, points it at their own gear and saves. Nothing is written until they do, and there is no second creation path to keep working: one request, the same as any other automation. Every recipe targets loopback, so one saved without thinking cannot put traffic on a venue network. The list now answers what an automation does without opening it: when it runs, whether it filters, and what it sends. An automation with no trigger or no output is the common half-finished state and is called out rather than shown as a blank cell. Outputs are cards with their type, a summary and a Test button in the header, which is also where the test says whether it worked: the panel used to send and tell the user nothing either way. Deleting confirms first and names the triggers that go with it, instead of dumping the server's refusal into a stray row under the table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf --- .../AutomationForm.module.scss | 113 +-- .../automations-panel/AutomationForm.tsx | 717 +++++++++++------- .../automations-panel/AutomationPanel.tsx | 10 +- .../AutomationSettingsForm.tsx | 3 +- .../AutomationsList.module.scss | 20 + .../automations-panel/AutomationsList.tsx | 178 +++-- .../DeleteAutomationDialog.tsx | 96 +++ .../NewAutomationDialog.module.scss | 58 ++ .../automations-panel/NewAutomationDialog.tsx | 79 ++ .../automations-panel/OntimeActionForm.tsx | 15 +- .../panel/automations-panel/TriggersList.tsx | 42 +- .../__tests__/automationRecipes.test.ts | 65 ++ .../automations-panel/automationRecipes.ts | 119 +++ 13 files changed, 1106 insertions(+), 409 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx 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/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..f20036324 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,21 +1,28 @@ import { Automation, AutomationDTO, - HTTPOutput, - OSCOutput, - OntimeAction, + AutomationFilter, + TimerLifeCycle, + Trigger, isHTTPOutput, 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 { + addAutomation, + addTrigger, + deleteTrigger, + 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'; @@ -27,7 +34,7 @@ import useAutomationSettings from '../../../../common/hooks-query/useAutomationS import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import { startsWithHttp } from '../../../../common/utils/regex'; import * as Panel from '../../panel-utils/PanelUtils'; -import { isAutomation, makeFieldList } from './automationUtils'; +import { cycles, isAutomation, makeFieldList, operators } from './automationUtils'; import OntimeActionForm from './OntimeActionForm'; import TemplateInput from './template-input/TemplateInput'; @@ -36,17 +43,72 @@ 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 }; + +/** lifecycles that fire continuously, and are worth a warning before a user picks one */ +const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate]; + interface AutomationFormProps { automation: Automation | AutomationDTO; + /** global triggers, used to resolve which lifecycles this automation is currently bound to */ + triggers: Trigger[]; + /** lifecycles a new automation starts with selected, used by recipes */ + defaultCycles?: TimerLifeCycle[]; onClose: () => void; } -export default function AutomationForm({ automation, onClose }: AutomationFormProps) { +export default function AutomationForm({ automation, triggers, defaultCycles, onClose }: AutomationFormProps) { const isEdit = isAutomation(automation); const { data } = useCustomFields(); const { refetch } = useAutomationSettings(); const fieldList = useMemo(() => makeFieldList(data), [data]); + /** + * Triggers are a separate entity, so they live outside the form state. + * + * We snapshot the automation's triggers when the form opens and reconcile against that + * snapshot, never against the live prop: settings are polled, so a trigger created + * elsewhere while this form is open must not be deleted by a save that never saw it. + */ + const [initialTriggers] = useState(() => + isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [], + ); + const initialCycles = useMemo( + () => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))), + [initialTriggers], + ); + // a new automation can arrive pre-filled from a recipe, an existing one resolves its own triggers + const [selectedCycles, setSelectedCycles] = useState( + isEdit ? initialCycles : (defaultCycles ?? []), + ); + /** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */ + const [createdId, setCreatedId] = useState(null); + + const cyclesAreDirty = + selectedCycles.length !== initialCycles.length || + selectedCycles.some((cycle) => !initialCycles.includes(cycle)) || + initialCycles.some((cycle) => !selectedCycles.includes(cycle)); + + const toggleCycle = (cycle: TimerLifeCycle) => { + setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle])); + }; + + /** + * A lifecycle can carry several differently named triggers, which the chips collapse into one. + * Unchecking it removes all of them, so say which ones rather than deleting them quietly. + */ + const triggersToRemove = initialTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger)); + + /** + * 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 +155,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,80 +192,101 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr appendOutput({ type: 'ontime', action: 'aux1-start' }); }; - const handleTestOSCOutput = async (index: number) => { + /** + * Sends a single output as configured, without saving the automation. + * OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it. + */ + const handleTest = async (index: number, key: string) => { + const values = getValues(`outputs.${index}`); + + if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) { + reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' }); + return; + } + if (isHTTPOutput(values) && !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 OSCOutput; - if (!values.targetIP || !values.targetPort || !values.address) { - return; - } - await testOutput({ - type: 'osc', - targetIP: values.targetIP, - targetPort: values.targetPort, - address: values.address, - args: values.args, - }); - } catch (_error) { - /** we dont handle errors here, users should use the network tab */ + // NOTE: there is no meaningful validation to do on an Ontime action, we let the server deal with the data + await testOutput(values); + reportTest(key, { status: 'ok', message: 'Sent' }); + } catch (error) { + reportTest(key, { status: 'error', message: maybeAxiosError(error) }); } }; - const handleTestHTTPOutput = async (index: number) => { - 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 */ + /** + * Reconciles the lifecycle selection against the global triggers. + * Runs after the automation itself is saved: a new automation has no id until then. + * + * Both sides are diffed against the mount-time snapshot, so this only ever removes + * triggers the user could actually see when they made the change. + */ + const syncTriggers = async (automationId: string, title: string) => { + for (const trigger of triggersToRemove) { + await deleteTrigger(trigger.id); } - }; - const handleTestOntimeAction = async (index: number) => { - 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 */ + const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle)); + for (const cycle of toAdd) { + const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle; + await addTrigger({ title: `${title} — ${label}`, trigger: cycle, automationId }); } }; const onSubmit = async (values: AutomationDTO) => { - if (isAutomation(automation)) { - await handleEdit(automation.id, { id: automation.id, ...values }); - } else { - await handleCreate(values); + // saving happens in two requests, so a retry after a partial failure must edit rather than create again + const existingId = isAutomation(automation) ? automation.id : createdId; + let automationId: string; + + try { + if (existingId) { + await editAutomation(existingId, { id: existingId, ...values }); + automationId = existingId; + } else { + const created = await addAutomation(values); + setCreatedId(created.id); + automationId = created.id; + } + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + return; } + + try { + await syncTriggers(automationId, values.title); + } catch (error) { + // the automation itself is saved, only its triggers failed. Keep the form open so the user can retry + refetch(); + setError('root', { message: `Automation saved, but its triggers failed: ${maybeAxiosError(error)}` }); + return; + } + refetch(); - - async function handleEdit(id: string, values: Automation) { - try { - await editAutomation(id, values); - onClose(); - } catch (error) { - setError('root', { message: maybeAxiosError(error) }); - } - } - - async function handleCreate(values: AutomationDTO) { - try { - await addAutomation(values); - onClose(); - } catch (error) { - setError('root', { message: maybeAxiosError(error) }); - } - } + onClose(); }; - const canSubmit = !isSubmitting && isDirty && isValid; + /** 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'}`; + }; + + // a recipe arrives complete, so a new automation is savable without the user changing anything + const canSubmit = !isSubmitting && (!isEdit || isDirty || cyclesAreDirty) && isValid; + const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle)); return ( @@ -207,87 +311,119 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr {errors.title?.message} + +
+ + + Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a + single event from the event editor. + + + {cycles.map(({ id, label, value }) => { + const cycle = value as TimerLifeCycle; + const isSelected = selectedCycles.includes(cycle); + return ( + + ); + })} + + {hasContinuousCycle && ( + + Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you mean + to send on every tick. + + )} + {triggersToRemove.length > 0 && ( + + {`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove + .map((trigger) => trigger.title) + .join(', ')}`} + + )} +

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)} + > + + +
+
+ +
); @@ -303,12 +439,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr

Outputs

- Automation outputs can be used to send data from Ontime to external software
- or to change properties of Ontime itself.

- Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} + Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '} read the docs
+ {fieldOutputs.length === 0 && ( + + )} + {fieldOutputs.map((output, index) => { if (isOSCOutput(output)) { const rowErrors = errors.outputs?.[index] as @@ -321,75 +462,61 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr | undefined; return ( -
- OSC -
- - - - -
-   - - - removeOutput(index)} - > - - - -
-
-
+ handleTest(index, output.id)} + onDelete={() => removeOutput(index)} + > + + + + + ); } + if (isHTTPOutput(output)) { const rowErrors = errors.outputs?.[index] as | { @@ -397,42 +524,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } | undefined; return ( -
- HTTP -
- -
-   - - - removeOutput(index)} - > - - - -
-
-
+ handleTest(index, output.id)} + onDelete={() => removeOutput(index)} + > + + ); } @@ -447,8 +563,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } | undefined; return ( -
- Ontime action + handleTest(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 +627,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..47cfe46a5 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,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) { />
- -
-
-
+
+ +
); } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx index 184b04926..3ae8ed702 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx @@ -94,7 +94,8 @@ export default function AutomationSettingsForm({ 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..959f0404c --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss @@ -0,0 +1,20 @@ +/** tags make a cell taller than the title beside it, which staggers on the default baseline */ +.table td { + vertical-align: middle; +} + +.tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.actions { + justify-content: flex-end; + flex-wrap: nowrap; +} + +.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..649927881 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,57 +1,98 @@ -import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; -import { Fragment, useState } from 'react'; +import { Automation, AutomationDTO, NormalisedAutomation, TimerLifeCycle, Trigger } from 'ontime-types'; +import { useMemo, useState } from 'react'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; -import { deleteAutomation } 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 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 { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../../panel-utils/PanelUtils'; +import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import AutomationForm from './AutomationForm'; +import type { AutomationRecipe } from './automationRecipes'; +import { groupTriggersByAutomation, isAutomation } from './automationUtils'; +import DeleteAutomationDialog from './DeleteAutomationDialog'; +import NewAutomationDialog from './NewAutomationDialog'; -const automationPlaceholder: AutomationDTO = { +import style from './AutomationsList.module.scss'; + +const emptyAutomation: AutomationDTO = { title: '', filterRule: 'all', filters: [], outputs: [], }; +/** what the automation form opens with: an existing automation, or a blank/pre-filled draft */ +type FormState = { + automation: Automation | AutomationDTO; + /** only used when creating, an existing automation resolves its own lifecycles */ + defaultCycles?: TimerLifeCycle[]; +}; + 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); + const { setLocation } = useAppSettingsNavigation(); + const [formState, setFormState] = useState(null); + const [isPickingStart, setIsPickingStart] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); - const handleDelete = async (id: string) => { - try { - setDeleteError(null); - await deleteAutomation(id); - } catch (error) { - setDeleteError(maybeAxiosError(error)); - } finally { - refetch(); - } + const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); + const automationIds = Object.keys(automations); + + /** a recipe is only ever a pre-filled form, nothing is written until the user saves */ + const handleStartFrom = (recipe: AutomationRecipe | null) => { + setIsPickingStart(false); + setFormState({ automation: recipe?.automation ?? emptyAutomation, defaultCycles: recipe?.triggers }); }; - const arrayAutomations = Object.keys(automations); + const handleDeleted = async () => { + setDeleteTarget(null); + await refetch(); + }; return ( - {automationFormData !== null && ( - setAutomationFormData(null)} /> + {formState !== null && ( + setFormState(null)} + /> + )} + {isPickingStart && setIsPickingStart(false)} onSelect={handleStartFrom} />} + {deleteTarget !== null && ( + trigger.automationId === deleteTarget.id)} + onCancel={() => setDeleteTarget(null)} + onDeleted={handleDeleted} + /> )} Manage automations - @@ -60,74 +101,95 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa {enabledAutomations === false && ( - - Automations are disabled. You can still manage automation definitions here, but they will not run until - enabled. + + Automations are off, so nothing in this list will run. + + {/* the master switch is at the top of the panel, out of sight once the list has rows */} + + )} - + - Title - Trigger rule - Filters - Outputs + Title + Runs on + Filter rule + Sends - {!isLoading && arrayAutomations.length === 0 && ( + {!isLoading && automationIds.length === 0 && ( setAutomationFormData(automationPlaceholder)}> - Create automation + } /> )} - {arrayAutomations.map((automationId) => { - if (!Object.hasOwn(automations, automationId)) { - return null; - } + {automationIds.map((automationId) => { + const automation = automations[automationId]; + const lifecycles = lifecyclesByAutomation[automationId] ?? []; + const outputs = summariseOutputs(automation.outputs); + return ( - - - {automations[automationId].title} - - {automations[automationId].filterRule} - - {automations[automationId].filters.length} - {automations[automationId].outputs.length} - + + {automation.title} + +
+ {lifecycles.length === 0 ? ( + Never runs + ) : ( + lifecycles.map((cycle) => {getLifecycleLabel(cycle)}) + )} +
+ + + {automation.filters.length === 0 ? ( + + ) : ( + {automation.filterRule === 'all' ? 'All filters' : 'Any filter'} + )} + + +
+ {outputs.length === 0 ? ( + No outputs + ) : ( + outputs.map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + )) + )} +
+ + +
setAutomationFormData(automations[automationId])} + onClick={() => setFormState({ automation })} > handleDelete(automationId)} + onClick={() => setDeleteTarget(automation)} > - - - +
+ + ); })} - {deleteError && ( - - - {deleteError} - - - )}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx new file mode 100644 index 000000000..ed16eb99d --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx @@ -0,0 +1,96 @@ +import type { Automation, Trigger } from 'ontime-types'; +import { useState } from 'react'; + +import { deleteAutomation } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Button from '../../../../common/components/buttons/Button'; +import Dialog from '../../../../common/components/dialog/Dialog'; +import Info from '../../../../common/components/info/Info'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; +import * as Panel from '../../panel-utils/PanelUtils'; + +interface DeleteAutomationDialogProps { + automation: Automation; + /** global triggers pointing at this automation, they are deleted along with it */ + attachedTriggers: Trigger[]; + onCancel: () => void; + onDeleted: () => void; +} + +/** + * Deleting takes the automation's global triggers with it, so say so before it happens rather + * than leaving the user to discover it in the triggers list. + * + * An automation attached to an event is still refused by the server: that reference lives in + * the rundown and removing it is an edit to the show, not to this panel. + */ +export default function DeleteAutomationDialog({ + automation, + attachedTriggers, + onCancel, + onDeleted, +}: DeleteAutomationDialogProps) { + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setError(null); + setIsDeleting(true); + try { + await deleteAutomation(automation.id); + onDeleted(); + } catch (error) { + setError(maybeAxiosError(error)); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + Delete {automation.title}? This cannot be undone. + + + {attachedTriggers.length > 0 && ( + + + {attachedTriggers.length === 1 + ? 'Its trigger is deleted with it' + : `Its ${attachedTriggers.length} triggers are deleted with it`} + + {attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')} + + )} + + {error && ( + + Could not delete this automation + {error} + + An automation attached to a single event has to be removed from that event first, in the event editor. + + + )} +
+ } + footerElements={ + <> + + + + } + /> + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss new file mode 100644 index 000000000..3f36e7bd6 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss @@ -0,0 +1,58 @@ +.list { + display: flex; + flex-direction: column; + gap: 0.5rem; + color: $ui-white; +} + +.listLabel { + margin-top: 0.5rem; + font-size: $aux-text-size; + color: $secondary-text-gray; +} + +.option { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem; + text-align: left; + color: inherit; + background-color: $black-10; + border: 1px solid $white-10; + border-radius: $component-border-radius-md; + cursor: pointer; + + &:hover { + background-color: $white-3; + border-color: $white-20; + } + + &:focus-visible { + outline: 1px solid $action-blue; + outline-offset: 1px; + } +} + +.optionText { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; + min-width: 0; +} + +.optionTitle { + font-weight: 600; +} + +.optionDescription { + font-size: $aux-text-size; + color: $secondary-text-gray; +} + +.chevron { + flex-shrink: 0; + color: $secondary-text-gray; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx new file mode 100644 index 000000000..cfb50568b --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx @@ -0,0 +1,79 @@ +import { IoChevronForward } from 'react-icons/io5'; + +import Modal from '../../../../common/components/modal/Modal'; +import Tag from '../../../../common/components/tag/Tag'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; +import { summariseOutputs } from '../../../../common/utils/automationOutputs'; +import { isOntimeCloud } from '../../../../externals'; +import * as Panel from '../../panel-utils/PanelUtils'; +import { automationRecipes, needsTarget, type AutomationRecipe } from './automationRecipes'; + +import style from './NewAutomationDialog.module.scss'; + +interface NewAutomationDialogProps { + onClose: () => void; + /** called with the recipe to pre-fill the form with, or null to start from an empty one */ + onSelect: (recipe: AutomationRecipe | null) => void; +} + +/** + * The single entry point for making an automation: a list of starting points. + * Picking one opens the ordinary automation form pre-filled, so a recipe is a head start + * rather than a separate kind of object. Nothing is saved until the user saves the form. + */ +export default function NewAutomationDialog({ onClose, onSelect }: NewAutomationDialogProps) { + // OSC is not available in the cloud service, offering those recipes there would be a lie + const recipes = isOntimeCloud + ? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc')) + : automationRecipes; + + return ( + + + +
+ Or start from a recipe. Each one opens as a normal automation you can edit before saving. +
+ + {recipes.map((recipe) => ( + + ))} +
+ } + /> + ); +} 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 ( -
+ <>