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..e2a3da972 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 @@ -1,13 +1,27 @@ +/** + * The wide modal body does not scroll, so the form owns it. + * Without this the form is simply clipped: four outputs is enough to put Save out of reach. + */ +.form { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.formScroll { + height: 100%; +} + .outerColumn { display: flex; flex-direction: column; gap: 2rem; font-size: calc(1rem - 1px); color: $ui-white; - - // the shared modal body owns scrolling for this regular form modal - min-height: 100%; padding-block: 0.5rem; + // leaves the overlay scrollbar somewhere to sit without covering a field + padding-right: 0.5rem; h3 { font-size: 1rem; @@ -26,61 +40,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..d9a815273 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,53 +1,108 @@ import { Automation, AutomationDTO, - HTTPOutput, - OSCOutput, - OntimeAction, + AutomationFilter, + TimerLifeCycle, + Trigger, isHTTPOutput, isOSCOutput, isOntimeAction, } from 'ontime-types'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { IoAdd, 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'; import Modal from '../../../../common/components/modal/Modal'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; +import ScrollArea from '../../../../common/components/scroll-area/ScrollArea'; import Select from '../../../../common/components/select/Select'; 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 * as Panel from '../../panel-utils/PanelUtils'; -import { isAutomation, makeFieldList } from './automationUtils'; +import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils'; +import HttpOutputForm from './HttpOutputForm'; import OntimeActionForm from './OntimeActionForm'; -import TemplateInput from './template-input/TemplateInput'; +import OscOutputForm from './OscOutputForm'; +import OutputCard, { type TestState } from './OutputCard'; 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; + +/** 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[]; onClose: () => void; } -export default function AutomationForm({ automation, onClose }: AutomationFormProps) { +export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) { const isEdit = isAutomation(automation); const { data } = useCustomFields(); const { refetch } = useAutomationSettings(); const fieldList = useMemo(() => makeFieldList(data), [data]); + /** + * The triggers the server holds for this automation, as far as this form knows. + * + * Seeded from a snapshot taken when the form opens, never from 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. It then advances as each request succeeds, so a save that fails + * half way leaves only the outstanding work for the retry. + */ + const [syncedTriggers, setSyncedTriggers] = useState(() => + isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [], + ); + const syncedCycles = useMemo( + () => Array.from(new Set(syncedTriggers.map((trigger) => trigger.trigger))), + [syncedTriggers], + ); + const [selectedCycles, setSelectedCycles] = useState(syncedCycles); + // both are deduped, so equal lengths and one being a subset makes them the same selection + const cyclesAreDirty = + selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.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 = syncedTriggers.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 { + clearErrors, control, handleSubmit, getValues, @@ -60,10 +115,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } = useForm({ mode: 'onChange', defaultValues: { - title: automation?.title ?? '', - filterRule: automation?.filterRule ?? 'all', - filters: automation?.filters ?? [], - outputs: automation?.outputs ?? [], + title: automation.title, + filterRule: automation.filterRule, + filters: automation.filters, + outputs: automation.outputs, }, resetOptions: { keepDirtyValues: true, @@ -93,6 +148,28 @@ 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 getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined; + const handleAddNewFilter = () => { appendFilter({ field: '', operator: 'equals', value: '' }); }; @@ -106,84 +183,115 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr appendOutput({ type: 'http', url: '' }); }; - const handleAddnewOntimeAction = () => { + const handleAddNewOntimeAction = () => { 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 an existing automation's lifecycle selection against the global triggers. + * New automations and their triggers are created together in one request. + * + * Every request advances the synced snapshot as it succeeds, so pressing save again after + * a failure half way through retries only what is left. Without that a retry would re-add + * a trigger it already created, and re-delete one it already deleted, which the server + * rejects outright. + */ + const syncTriggers = async (automationId: string, title: string) => { + for (const trigger of triggersToRemove) { + await deleteTrigger(trigger.id); + setSyncedTriggers((prev) => prev.filter((synced) => synced.id !== 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) => !syncedCycles.includes(cycle)); + for (const cycle of toAdd) { + const created = await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId }); + setSyncedTriggers((prev) => [...prev, created]); } }; const onSubmit = async (values: AutomationDTO) => { - if (isAutomation(automation)) { - await handleEdit(automation.id, { id: automation.id, ...values }); - } else { - await handleCreate(values); + // a stale failure from the previous attempt would otherwise sit under a successful retry + clearErrors('root'); + + try { + if (!isAutomation(automation)) { + await addAutomation( + values, + selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })), + ); + refetch(); + onClose(); + return; + } + + await editAutomation(automation.id, { id: automation.id, ...values }); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + return; } + + try { + await syncTriggers(automation.id, 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 failed save reports itself as a root error, which react-hook-form counts against + * isValid. Left alone that disables the very retry the message is asking the user to make, + * so a root error on its own does not block submitting again. + */ + const invalidFields = Object.keys(errors).filter((field) => field !== 'root'); + const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0); + const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle)); return ( -
-

Automation options

-
- - {errors.title?.message} -
-
+
+ +
+

Automation options

+
+ + {errors.title?.message} +
-
-

Filters (optional)

-
- - {fieldFilters.map((field, index) => { - const key = `filters.${index}.field.${field.id}`; - return ( -
- - -
-   -
+
+ + + 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(({ label, value }) => { + const isSelected = selectedCycles.includes(value); + 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 description = describeFilter(index); + return ( +
+
+ Filter + {description} removeFilter(index)} >
+
+ + +
-
- ); - })} -
- + ); + })} +
+ +
-
-
-

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{' '} - read the docs -
+
+

Outputs

+ + Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '} + read the docs + - {fieldOutputs.map((output, index) => { - if (isOSCOutput(output)) { - const rowErrors = errors.outputs?.[index] as - | { - targetIP?: { message?: string }; - targetPort?: { message?: string }; - address?: { message?: string }; - args?: { message?: string }; - } - | undefined; + {fieldOutputs.length === 0 && ( + + )} - return ( -
- OSC -
- - - - -
-   - - - removeOutput(index)} - > - - - -
-
-
- ); - } - if (isHTTPOutput(output)) { - const rowErrors = errors.outputs?.[index] as - | { - url?: { message?: string }; - } - | undefined; - return ( -
- HTTP -
- -
-   - - - removeOutput(index)} - > - - - -
-
-
- ); - } + {fieldOutputs.map((output, index) => { + const rowErrors = getOutputErrors(index); + const cardProps = { + testState: testResults[output.id], + onTest: () => handleTest(index, output.id), + onDelete: () => removeOutput(index), + }; - if (isOntimeAction(output)) { - const rowErrors = errors.outputs?.[index] as - | { - action?: { message?: string }; - time?: { message?: string }; - text?: { message?: string }; - visible?: { message?: string }; - secondarySource?: { message?: string }; - } - | undefined; - return ( -
- Ontime action - -   - - - removeOutput(index)} - > - - - - -
- ); - } + + + ); + } - return null; - })} - - - - - -
+ if (isHTTPOutput(output)) { + return ( + + + + ); + } + + if (isOntimeAction(output)) { + return ( + + + + ); + } + + 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 + +
+
+ } footerElements={ 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..8fd2df6ed 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,18 +1,25 @@ -import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; -import { Fragment, useState } from 'react'; +import { Automation, AutomationDTO, NormalisedAutomation, 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 { 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: [], @@ -21,37 +28,73 @@ 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); + const { setLocation } = useAppSettingsNavigation(); + const [editing, setEditing] = 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 automationList = Object.values(automations); + + /** a recipe creates the automation itself, so it lands in the list rather than in a form */ + const handleCreated = async () => { + setIsPickingStart(false); + await refetch(); }; - const arrayAutomations = Object.keys(automations); + const handleStartEmpty = () => { + setIsPickingStart(false); + setEditing(emptyAutomation); + }; + + const handleDeleted = async () => { + setDeleteTarget(null); + await refetch(); + }; return ( - {automationFormData !== null && ( - setAutomationFormData(null)} /> + {editing !== null && ( + setEditing(null)} + /> + )} + {isPickingStart && ( + setIsPickingStart(false)} + onStartEmpty={handleStartEmpty} + onCreated={handleCreated} + /> + )} + {deleteTarget !== null && ( + trigger.automationId === deleteTarget.id)} + onCancel={() => setDeleteTarget(null)} + onDeleted={handleDeleted} + /> )} Manage automations - @@ -60,74 +103,101 @@ 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 && automationList.length === 0 && ( setAutomationFormData(automationPlaceholder)}> - Create automation + } /> )} - {arrayAutomations.map((automationId) => { - if (!Object.hasOwn(automations, automationId)) { - return null; - } + {automationList.map((automation) => { + const lifecycles = lifecyclesByAutomation[automation.id] ?? []; + const outputs = summariseOutputs(automation.outputs); + return ( - - - {automations[automationId].title} - - {automations[automationId].filterRule} - - {automations[automationId].filters.length} - {automations[automationId].outputs.length} - + + {automation.title} + + {/* + * Only global triggers are visible here: an automation can also be attached to + * single events, which live in the rundown. An empty cell is therefore not the + * same as never running, so it says nothing rather than claiming that. + */} + {lifecycles.length === 0 ? ( + + ) : ( +
+ {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={() => setEditing(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/HttpOutputForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/HttpOutputForm.tsx new file mode 100644 index 000000000..349815a71 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/HttpOutputForm.tsx @@ -0,0 +1,34 @@ +import type { AutomationDTO, HTTPOutput } from 'ontime-types'; +import type { UseFormRegister } from 'react-hook-form'; + +import { startsWithHttp } from '../../../../common/utils/regex'; +import * as Panel from '../../panel-utils/PanelUtils'; +import type { OutputErrors } from './automationUtils'; +import TemplateInput from './template-input/TemplateInput'; + +import style from './AutomationForm.module.scss'; + +interface HttpOutputFormProps { + index: number; + output: HTTPOutput; + register: UseFormRegister; + rowErrors?: OutputErrors; +} + +export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) { + return ( + + ); +} 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..abc861e86 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,10 +1,11 @@ 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'; import Select from '../../../../common/components/select/Select'; import * as Panel from '../../panel-utils/PanelUtils'; +import type { OutputErrors } from './automationUtils'; import TemplateInput from './template-input/TemplateInput'; import style from './AutomationForm.module.scss'; @@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss'; interface OntimeActionFormProps { index: number; register: UseFormRegister; - rowErrors?: { - action?: { message?: string }; - time?: { message?: string }; - text?: { message?: string }; - visible?: { message?: string }; - secondarySource?: { message?: string }; - }; + rowErrors?: OutputErrors; value: OntimeAction['action']; watch: UseFormWatch; setValue: UseFormSetValue; @@ -30,9 +25,8 @@ export default function OntimeActionForm({ setValue, rowErrors, value, - children, watch, -}: PropsWithChildren) { +}: OntimeActionFormProps) { const [selectedAction, setSelectedAction] = useState(value); const handleSetAction = (value: OntimeActionKey) => { @@ -41,7 +35,7 @@ export default function OntimeActionForm({ }; return ( -
+ <> + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/OutputCard.tsx b/apps/client/src/features/app-settings/panel/automations-panel/OutputCard.tsx new file mode 100644 index 000000000..8c2947c2f --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/OutputCard.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react'; +import { IoCheckmark, IoTrash } from 'react-icons/io5'; + +import Button from '../../../../common/components/buttons/Button'; +import IconButton from '../../../../common/components/buttons/IconButton'; +import Tag from '../../../../common/components/tag/Tag'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import style from './AutomationForm.module.scss'; + +export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string }; + +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 + */ +export default 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/TriggersList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx index f8c76531b..5e43c9139 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx @@ -1,13 +1,13 @@ import { NormalisedAutomation, Trigger } from 'ontime-types'; -import { Fragment, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { IoAdd } from 'react-icons/io5'; import { deleteTrigger } from '../../../../common/api/automation'; import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; -import Info from '../../../../common/components/info/Info'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import * as Panel from '../../panel-utils/PanelUtils'; +import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import { checkDuplicates } from './automationUtils'; import TriggerForm from './TriggerForm'; import TriggersListItem from './TriggersListItem'; @@ -20,13 +20,13 @@ type FormState = { interface TriggersListProps { triggers: Trigger[]; automations: NormalisedAutomation; - enabledAutomations?: boolean; isLoading: boolean; } -export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) { +export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) { const [formState, setFormState] = useState({ isOpen: false, trigger: undefined }); const { refetch } = useAutomationSettings(); + const { setLocation } = useAppSettingsNavigation(); const [deleteError, setDeleteError] = useState(null); const openNewForm = () => setFormState({ isOpen: true }); @@ -50,6 +50,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations }; const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]); + const orphans = useMemo( + () => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length, + [triggers, automations], + ); // there is no point letting user creating a trigger if there are no automations const canAdd = Object.keys(automations).length > 0; @@ -66,22 +70,28 @@ export default function TriggersList({ triggers, automations, enabledAutomations /> )} - Manage triggers + Global triggers - {enabledAutomations === false && ( - - Automations are disabled. You can still manage triggers here, but they will not run until enabled. - - )} + + Triggers are managed from the automation itself. This list is for naming them, or for pointing several + differently named triggers at the same automation. + {duplicates && ( - You have created multiple links between the same trigger and automation which can cause performance - issues. + You have created multiple links between the same trigger and automation. Duplicate combinations will only + fire once per lifecycle event. + + )} + {orphans > 0 && ( + + {orphans === 1 + ? '1 trigger points at an automation that no longer exists and will never run.' + : `${orphans} triggers point at automations that no longer exist and will never run.`} )} @@ -99,31 +109,32 @@ export default function TriggersList({ triggers, automations, enabledAutomations title='No triggers yet' description={ canAdd - ? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.' - : 'Create an automation first, then add a trigger to decide when it should run.' + ? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.' + : 'Create an automation first, then pick the lifecycles it should run on.' } action={ - canAdd && ( + canAdd ? ( + ) : ( + ) } /> )} - {triggers.map((trigger, index) => { - return ( - - openEditForm(trigger)} - handleDelete={() => handleDelete(trigger.id)} - /> - - ); - })} + {triggers.map((trigger, index) => ( + openEditForm(trigger)} + handleDelete={() => handleDelete(trigger.id)} + /> + ))} {deleteError && ( 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..e359f0370 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 @@ -16,6 +16,7 @@ interface TriggersListItemProps { export default function TriggersListItem(props: TriggersListItemProps) { const { automations, trigger, duplicate, handleEdit, handleDelete } = props; + const automation = automations[trigger.automationId]; return ( @@ -31,7 +32,8 @@ 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 */} + {automation ? {automation.title} : Missing automation} 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'], }, ],