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..031e84b36 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,22 @@ +.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; + padding-right: 0.5rem; h3 { font-size: 1rem; @@ -26,61 +35,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..325a5a513 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,14 +1,14 @@ 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'; @@ -16,38 +16,70 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap 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 { isOntimeCloud } from '../../../../externals'; 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; + 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]); + const [initialCycles] = useState(() => + isAutomation(automation) + ? Array.from( + new Set( + triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger), + ), + ) + : [], + ); + const [selectedCycles, setSelectedCycles] = useState(initialCycles); + const cyclesAreDirty = + selectedCycles.length !== initialCycles.length || selectedCycles.some((cycle) => !initialCycles.includes(cycle)); + + const toggleCycle = (cycle: TimerLifeCycle) => { + setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle])); + }; + + const [testResults, setTestResults] = useState>({}); + const feedbackTimers = useRef>>({}); + const { + clearErrors, control, handleSubmit, getValues, @@ -60,10 +92,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, @@ -88,11 +120,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr control, }); - // give initial focus to the title field useEffect(() => { setFocus('title'); }, [setFocus]); + 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 +158,86 @@ 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) => { - 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 */ - } - }; + /** + * 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}`); - 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 */ + 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; } - }; - const handleTestOntimeAction = async (index: number) => { + reportTest(key, { status: 'sending' }); try { - const values = getValues(`outputs.${index}`) as OntimeAction; - // NOTE: there is no meaningful validation to do here, we let the server deal with the data - await testOutput({ - ...values, - type: 'ontime', - }); - } catch (_error) { - /** we dont handle errors here */ + await testOutput(values); + reportTest(key, { status: 'ok', message: 'Request sent' }); + } catch (error) { + reportTest(key, { status: 'error', message: maybeAxiosError(error) }); } }; 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 }, + selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })), + ); + } catch (error) { + setError('root', { message: 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; + 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. + + )} +
+
+ +
+

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={[ + ...(isOntimeCloud + ? [] + : [ + { + type: 'item' as const, + 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/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..c780b4c67 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/OutputCard.tsx @@ -0,0 +1,60 @@ +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; + unavailableReason?: string; + onTest: () => void; + onDelete: () => void; + children: ReactNode; +} + +export default function OutputCard({ + label, + kindClass, + summary, + testState, + unavailableReason, + onTest, + onDelete, + children, +}: OutputCardProps) { + return ( +
+
+ {label} + {summary} + {testState?.status === 'ok' && ( + + + {testState.message} + + )} + {unavailableReason ? ( + {unavailableReason} + ) : ( + + )} + + + +
+ {testState?.status === 'error' && {testState.message}} +
{children}
+
+ ); +}