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 ( -
+ <>