From 25eb68452cfb74130984d4d294d4ac6497ee8c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:28:25 +0000 Subject: [PATCH] refactor(automation): split the output kinds out of the form AutomationForm rendered all three output kinds inline, so the file mixed the form's own concerns with the fields of every protocol it can speak. Each kind now has a component next to OntimeActionForm, which was already there and was the odd one out, and OutputCard owns the chrome they share. The form drops from ~700 lines to ~540 and the map over the outputs reads as three cases. Three copies of an inline structural cast existed only to reach an output field's error by name, which react-hook-form cannot resolve through a union. One OutputErrors type replaces all of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf --- .../automations-panel/AutomationForm.tsx | 166 +++--------------- .../automations-panel/HttpOutputForm.tsx | 34 ++++ .../automations-panel/OntimeActionForm.tsx | 9 +- .../panel/automations-panel/OscOutputForm.tsx | 63 +++++++ .../panel/automations-panel/OutputCard.tsx | 58 ++++++ .../automations-panel/automationUtils.ts | 6 + 6 files changed, 183 insertions(+), 153 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/HttpOutputForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/OscOutputForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/OutputCard.tsx 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 f20036324..2b8e18834 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 @@ -8,9 +8,9 @@ import { isOSCOutput, isOntimeAction, } from 'ontime-types'; -import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; -import { IoAdd, IoCheckmark, IoTrash } from 'react-icons/io5'; +import { IoAdd, IoTrash } from 'react-icons/io5'; import { addAutomation, @@ -32,11 +32,12 @@ 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 { cycles, isAutomation, makeFieldList, operators } from './automationUtils'; +import { cycles, 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'; @@ -46,8 +47,6 @@ 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]; @@ -175,6 +174,8 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on } }; + const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined; + const handleAddNewFilter = () => { appendFilter({ field: '', operator: 'equals', value: '' }); }; @@ -451,126 +452,38 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on )} {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 rowErrors = getOutputErrors(index); + const cardProps = { + testState: testResults[output.id], + onTest: () => handleTest(index, output.id), + onDelete: () => removeOutput(index), + }; + if (isOSCOutput(output)) { return ( handleTest(index, output.id)} - onDelete={() => removeOutput(index)} + {...cardProps} > - - - - + ); } if (isHTTPOutput(output)) { - const rowErrors = errors.outputs?.[index] as - | { - url?: { message?: string }; - } - | undefined; return ( - 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 ( - handleTest(index, output.id)} - onDelete={() => removeOutput(index)} - > + ); } - -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/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 e2cd556ec..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 @@ -5,6 +5,7 @@ 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; diff --git a/apps/client/src/features/app-settings/panel/automations-panel/OscOutputForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/OscOutputForm.tsx new file mode 100644 index 000000000..5dcfd4951 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/OscOutputForm.tsx @@ -0,0 +1,63 @@ +import type { AutomationDTO, OSCOutput } from 'ontime-types'; +import type { UseFormRegister } from 'react-hook-form'; + +import Input from '../../../../common/components/input/input/Input'; +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 OscOutputFormProps { + index: number; + output: OSCOutput; + register: UseFormRegister; + rowErrors?: OutputErrors; +} + +export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) { + 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/automationUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts index 7978f42e4..4685ed6ea 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts @@ -2,6 +2,12 @@ import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCyc import { lifecycleLabels } from '../../../../common/constants/timerLifecycle'; +/** + * Outputs are a union, so react-hook-form cannot resolve a field's error by name. + * Every output card knows which fields it registered, this just makes them reachable. + */ +export type OutputErrors = Partial>; + type CycleLabel = { id: number; label: string;