diff --git a/apps/client/src/common/api/__tests__/utils.test.ts b/apps/client/src/common/api/__tests__/utils.test.ts new file mode 100644 index 000000000..fc8adb438 --- /dev/null +++ b/apps/client/src/common/api/__tests__/utils.test.ts @@ -0,0 +1,25 @@ +import { maybeAxiosError } from '../utils'; + +describe('maybeAxiosError', () => { + it('shows the validation message without echoing the submitted value', () => { + const error = { + isAxiosError: true, + response: { + statusText: 'Unprocessable Entity', + data: { + errors: [ + { + type: 'field', + value: { title: 'Automation definition', outputs: [{ targetIP: 'not a host' }] }, + msg: 'Invalid OSC target', + path: '', + location: 'body', + }, + ], + }, + }, + }; + + expect(maybeAxiosError(error)).toBe('Unprocessable Entity: Invalid OSC target'); + }); +}); diff --git a/apps/client/src/common/api/utils.ts b/apps/client/src/common/api/utils.ts index 1f8701f24..c28669970 100644 --- a/apps/client/src/common/api/utils.ts +++ b/apps/client/src/common/api/utils.ts @@ -18,6 +18,15 @@ export function maybeAxiosError(error: unknown) { if (typeof data === 'object') { if ('message' in data) { data = JSON.stringify(data.message); + } else if ('errors' in data && Array.isArray(data.errors)) { + const firstError = data.errors.at(0); + data = + typeof firstError === 'object' && + firstError !== null && + 'msg' in firstError && + typeof firstError.msg === 'string' + ? firstError.msg + : JSON.stringify(data); } else { data = JSON.stringify(data); } diff --git a/apps/client/src/common/constants/__tests__/timerLifecycle.test.ts b/apps/client/src/common/constants/__tests__/timerLifecycle.test.ts new file mode 100644 index 000000000..00fbee749 --- /dev/null +++ b/apps/client/src/common/constants/__tests__/timerLifecycle.test.ts @@ -0,0 +1,13 @@ +import { TimerLifeCycle } from 'ontime-types'; + +import { getLifecycleLabel } from '../timerLifecycle'; + +describe('getLifecycleLabel', () => { + it('returns the shared label for known lifecycle values', () => { + expect(getLifecycleLabel(TimerLifeCycle.onClock)).toBe('Every second'); + }); + + it('keeps unknown lifecycle values visible', () => { + expect(getLifecycleLabel('future-lifecycle')).toBe('future-lifecycle'); + }); +}); diff --git a/apps/client/src/common/constants/timerLifecycle.ts b/apps/client/src/common/constants/timerLifecycle.ts new file mode 100644 index 000000000..3ed6d0659 --- /dev/null +++ b/apps/client/src/common/constants/timerLifecycle.ts @@ -0,0 +1,17 @@ +import { TimerLifeCycle } from 'ontime-types'; + +export const lifecycleLabels: Record = { + [TimerLifeCycle.onLoad]: 'On Load', + [TimerLifeCycle.onStart]: 'On Start', + [TimerLifeCycle.onPause]: 'On Pause', + [TimerLifeCycle.onStop]: 'On Stop', + [TimerLifeCycle.onClock]: 'Every second', + [TimerLifeCycle.onUpdate]: 'On Timer Update', + [TimerLifeCycle.onFinish]: 'On Finish', + [TimerLifeCycle.onWarning]: 'On Warning', + [TimerLifeCycle.onDanger]: 'On Danger', +}; + +export function getLifecycleLabel(cycle: TimerLifeCycle | string): string { + return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle; +} diff --git a/apps/client/src/common/utils/__tests__/automationOutputs.test.ts b/apps/client/src/common/utils/__tests__/automationOutputs.test.ts new file mode 100644 index 000000000..7ba5fe1a9 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/automationOutputs.test.ts @@ -0,0 +1,32 @@ +import type { AutomationOutput } from 'ontime-types'; + +import { summariseOutputs } from '../automationOutputs'; + +describe('summariseOutputs', () => { + it('returns an empty list when there are no outputs', () => { + expect(summariseOutputs([])).toEqual([]); + }); + + it('counts repeated output kinds', () => { + const outputs: AutomationOutput[] = [ + { type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' }, + { type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' }, + { type: 'http', url: 'http://127.0.0.1/start' }, + ]; + + expect(summariseOutputs(outputs)).toEqual([ + { type: 'osc', label: 'OSC', count: 2 }, + { type: 'http', label: 'HTTP', count: 1 }, + ]); + }); + + it('presents kinds in a stable order regardless of insertion order', () => { + const outputs: AutomationOutput[] = [ + { type: 'ontime', action: 'aux1-start' }, + { type: 'http', url: 'http://127.0.0.1/start' }, + { type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' }, + ]; + + expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']); + }); +}); diff --git a/apps/client/src/common/utils/automationOutputs.ts b/apps/client/src/common/utils/automationOutputs.ts new file mode 100644 index 000000000..234dc83cd --- /dev/null +++ b/apps/client/src/common/utils/automationOutputs.ts @@ -0,0 +1,26 @@ +import type { AutomationOutput } from 'ontime-types'; + +const outputLabels: Record = { + osc: 'OSC', + http: 'HTTP', + ontime: 'Ontime', +}; + +export type OutputSummary = { + type: AutomationOutput['type']; + label: string; + count: number; +}; + +export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] { + const counts = new Map(); + + for (const output of outputs) { + counts.set(output.type, (counts.get(output.type) ?? 0) + 1); + } + + const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime']; + return order + .filter((type) => counts.has(type)) + .map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number })); +} diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index 20dd0c838..ffc9be1ca 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -30,7 +30,8 @@ $content-max-width: 1280px; max-width: $content-max-width; margin: 0 auto 1rem; padding-inline: 1rem; - overflow-y: auto; + // Own both scroll axes so sticky table headers stay anchored to the panel viewport. + overflow: auto; flex-grow: 1; // room for the last section to scroll to the top of the viewport padding-bottom: 40vh; 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..c5ae23626 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 @@ -5,9 +5,8 @@ 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; @@ -27,10 +26,7 @@ } .titleSection, -.filterSection, -.oscSection, -.httpSection, -.actionSection { +.filterSection { display: grid; grid-gap: 0.5rem; @@ -41,10 +37,7 @@ .titleSection, .ruleSection, -.filterSection, -.oscSection, -.httpSection, -.actionSection { +.filterSection { label, div { // we use the div as non-interactive placeholder for button cells @@ -61,26 +54,85 @@ } .filterSection { - grid-template-columns: 2fr 1fr 2fr auto; + grid-template-columns: minmax(11rem, 1.25fr) minmax(10rem, 1fr) minmax(13rem, 1.75fr) auto; + align-items: end; } -.oscSection { - grid-template-columns: 9rem 5rem 3fr 4fr auto; -} +@media (max-width: $min-tablet) { + .filterSection { + grid-template-columns: 1fr 1fr auto; -.httpSection { - grid-template-columns: 1fr auto; -} - -.actionSection { - grid-template-columns: auto 1fr 1fr auto; - - .test { - grid-column: -1; + label:last-of-type { + grid-column: 1 / -1; + } } } -.outputCard { +.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; + + label { + display: block; + font-size: calc(1rem - 3px); + color: $label-gray; + } +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid $white-10; +} + +.cardSummary { + flex: 1; + min-width: 0; + overflow: hidden; + color: $secondary-text-gray; + font-size: $aux-text-size; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardBody { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.5rem 0.75rem; + padding: 0.75rem; +} + +.spanFull { + grid-column: 1 / -1; +} + +.testOk { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: $green-400; + font-size: $aux-text-size; +} + +.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..14c87581b 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,5 @@ -import { - Automation, - AutomationDTO, - HTTPOutput, - OSCOutput, - OntimeAction, - isHTTPOutput, - isOSCOutput, - isOntimeAction, -} from 'ontime-types'; -import { useEffect, useMemo } from 'react'; +import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { IoAdd, IoTrash } from 'react-icons/io5'; @@ -16,25 +7,28 @@ 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 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 { isAutomation, makeFieldList, 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'; +const testFeedbackDuration = 2000; interface AutomationFormProps { automation: Automation | AutomationDTO; @@ -46,13 +40,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr const { data } = useCustomFields(); const { refetch } = useAutomationSettings(); const fieldList = useMemo(() => makeFieldList(data), [data]); + const [testResults, setTestResults] = useState>({}); + const [submitError, setSubmitError] = useState(); + const feedbackTimers = useRef>>({}); const { control, handleSubmit, getValues, register, - setError, setFocus, setValue, watch, @@ -93,6 +89,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr setFocus('title'); }, [setFocus]); + // Clear delayed output-test feedback when the modal unmounts. + 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,57 +124,33 @@ 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 */ - } - }; + 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) => { + setSubmitError(undefined); if (isAutomation(automation)) { await handleEdit(automation.id, { id: automation.id, ...values }); } else { @@ -169,7 +163,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr await editAutomation(id, values); onClose(); } catch (error) { - setError('root', { message: maybeAxiosError(error) }); + setSubmitError(maybeAxiosError(error)); } } @@ -178,12 +172,43 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr await addAutomation(values); onClose(); } catch (error) { - setError('root', { message: maybeAxiosError(error) }); + setSubmitError(maybeAxiosError(error)); } } }; const canSubmit = !isSubmitting && isDirty && isValid; + const addOutputMenu = ( + } + items={[ + ...(isOntimeCloud + ? [] + : [ + { + type: 'item' as const, + label: 'OSC', + description: 'Send an OSC message to a device on the network', + onClick: handleAddNewOSCOutput, + }, + ]), + { + type: 'item' as const, + label: 'HTTP', + description: 'Call a URL, for webhooks and REST APIs', + onClick: handleAddNewHTTPOutput, + }, + { + type: 'item' as const, + label: 'Ontime action', + description: 'Change something inside Ontime, like a message or an aux timer', + onClick: handleAddNewOntimeAction, + }, + ]} + > + Add output + + ); return ( {errors.title?.message} @@ -223,6 +248,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr { value: 'any', label: 'Any filter passes' }, ]} /> + + All filters pass requires every condition to match. Any filter passes requires at least one match. + {fieldFilters.map((field, index) => { const key = `filters.${index}.field.${field.id}`; @@ -264,11 +292,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr { shouldDirty: true }, ); }} - options={[ - { value: 'equals', label: 'equals' }, - { value: 'not_equals', label: 'not equals' }, - { value: 'contains', label: 'contains' }, - ]} + options={operators} aria-label='Operator' /> {errors.filters?.[index]?.operator?.message} @@ -305,150 +329,52 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr 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 + + Use Ontime runtime data in these fields with template strings. Type{' '} + {'{{'} to see autocomplete, or{' '} + read the docs +
+ {fieldOutputs.length === 0 && ( + + )} {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; + const cardProps = { + testState: testResults[output.id], + onTest: () => handleTest(index, output.id), + onDelete: () => removeOutput(index), + }; + const rowErrors = getOutputErrors(index); + if (isOSCOutput(output)) { return ( -
- OSC -
- - - - -
-   - - - removeOutput(index)} - > - - - -
-
-
+ + + ); } if (isHTTPOutput(output)) { - const rowErrors = errors.outputs?.[index] as - | { - url?: { message?: string }; - } - | undefined; return ( -
- HTTP -
- -
-   - - - 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; })} - - - - - + {fieldOutputs.length > 0 && addOutputMenu} } footerElements={ <> - {errors?.root && {errors.root.message}} + {submitError && {submitError}} + )} + + + + + {testState?.status === 'error' && {testState.message}} +
{children}
+ + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.module.scss new file mode 100644 index 000000000..51300169a --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.module.scss @@ -0,0 +1,25 @@ +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.titleField { + display: block; +} + +.fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + + label { + min-width: 0; + } +} + +@media (max-width: $min-tablet) { + .fields { + grid-template-columns: 1fr; + } +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx index 3c8b2cd1c..7aafd27cf 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx @@ -11,6 +11,8 @@ import Select from '../../../../common/components/select/Select'; import * as Panel from '../../panel-utils/PanelUtils'; import { cycles } from './automationUtils'; +import style from './TriggerForm.module.scss'; + const formId = 'trigger-form'; interface TriggerFormProps { @@ -83,42 +85,45 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit size='compact' title={trigger ? 'Edit trigger' : 'Create trigger'} bodyElements={ -
-