feat(automation): improve automation output editing

Organize output editing into focused cards with validation, testing feedback, and lifecycle selection.
This commit is contained in:
Carlos Valente
2026-09-12 16:49:03 +02:00
parent 6e9fc36abc
commit 7a4a25fc55
6 changed files with 586 additions and 419 deletions
@@ -1,13 +1,22 @@
.form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.formScroll {
height: 100%;
}
.outerColumn { .outerColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
color: $ui-white; color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem; padding-block: 0.5rem;
padding-right: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -26,61 +35,84 @@
gap: 1rem; gap: 1rem;
} }
.titleSection, .titleSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-gap: 0.5rem; grid-gap: 0.5rem;
button {
align-self: flex-end;
}
} }
.titleSection, .titleSection,
.ruleSection, .ruleSection,
.filterSection, .card {
.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);
}
label { label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray; color: $label-gray;
} }
} }
.titleSection { /** shared shell for a single filter or output */
grid-template-columns: 1fr; .card {
} border: 1px solid $white-10;
.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 {
border-left: 0.25rem solid $gray-1200; 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;
} }
@@ -1,14 +1,14 @@
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
HTTPOutput, AutomationFilter,
OSCOutput, TimerLifeCycle,
OntimeAction, Trigger,
isHTTPOutput, isHTTPOutput,
isOSCOutput, isOSCOutput,
isOntimeAction, isOntimeAction,
} from 'ontime-types'; } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; 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 { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal'; import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; 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 * 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 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'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form'; 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 { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
triggers?: Trigger[];
onClose: () => void; onClose: () => void;
} }
export default function AutomationForm({ automation, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, triggers = [], onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
const [initialCycles] = useState<TimerLifeCycle[]>(() =>
isAutomation(automation)
? Array.from(
new Set(
triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger),
),
)
: [],
);
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(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<Record<string, TestState>>({});
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const { const {
clearErrors,
control, control,
handleSubmit, handleSubmit,
getValues, getValues,
@@ -60,10 +92,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
} = useForm<AutomationDTO>({ } = useForm<AutomationDTO>({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
title: automation?.title ?? '', title: automation.title,
filterRule: automation?.filterRule ?? 'all', filterRule: automation.filterRule,
filters: automation?.filters ?? [], filters: automation.filters,
outputs: automation?.outputs ?? [], outputs: automation.outputs,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -88,11 +120,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
control, control,
}); });
// give initial focus to the title field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
}, [setFocus]); }, [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 = () => { const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' }); appendFilter({ field: '', operator: 'equals', value: '' });
}; };
@@ -106,84 +158,86 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' }); appendOutput({ type: 'http', url: '' });
}; };
const handleAddnewOntimeAction = () => { const handleAddNewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' }); appendOutput({ type: 'ontime', action: 'aux1-start' });
}; };
const handleTestOSCOutput = async (index: number) => { /**
try { * Sends a single output as configured, without saving the automation.
const values = getValues(`outputs.${index}`) as OSCOutput; * OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
if (!values.targetIP || !values.targetPort || !values.address) { */
return; const handleTest = async (index: number, key: string) => {
} const values = getValues(`outputs.${index}`);
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 handleTestHTTPOutput = async (index: number) => { if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
try { reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
const values = getValues(`outputs.${index}`) as HTTPOutput; return;
if (!values.url) { }
return; if (isHTTPOutput(values) && !values.url) {
} reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
await testOutput({ return;
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
} }
};
const handleTestOntimeAction = async (index: number) => { reportTest(key, { status: 'sending' });
try { try {
const values = getValues(`outputs.${index}`) as OntimeAction; await testOutput(values);
// NOTE: there is no meaningful validation to do here, we let the server deal with the data reportTest(key, { status: 'ok', message: 'Request sent' });
await testOutput({ } catch (error) {
...values, reportTest(key, { status: 'error', message: maybeAxiosError(error) });
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
} }
}; };
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) { // a stale failure from the previous attempt would otherwise sit under a successful retry
await handleEdit(automation.id, { id: automation.id, ...values }); clearErrors('root');
} else {
await handleCreate(values); 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(); refetch();
onClose();
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) });
}
}
}; };
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 ( return (
<Modal <Modal
@@ -191,304 +245,236 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
onClose={onClose} onClose={onClose}
showBackdrop showBackdrop
showCloseButton showCloseButton
size='wide'
title={isEdit ? 'Edit automation' : 'Create automation'} title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={ bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<div className={style.innerColumn}> <ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
<h3>Automation options</h3> <div className={style.innerColumn}>
<div className={style.titleSection}> <h3>Automation options</h3>
<label> <div className={style.titleSection}>
Title <label>
<Input Title
{...register('title', { required: { value: true, message: 'Required field' } })} <Input
fluid {...register('title', { required: { value: true, message: 'Required field' } })}
placeholder='Load preset' fluid
/> placeholder='Load preset'
</label> />
<Panel.Error>{errors.title?.message}</Panel.Error> </label>
</div> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </div>
<div className={style.innerColumn}> <div className={style.titleSection}>
<h3>Filters (optional)</h3> <label id='runs-on-label'>Runs on</label>
<div className={style.ruleSection}> <Panel.Description>
<label> Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
Trigger outputs if single event from the event editor.
<RadioGroup </Panel.Description>
orientation='horizontal' <Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
value={watch('filterRule')} {cycles.map(({ label, value }) => {
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })} const isSelected = selectedCycles.includes(value);
items={[ return (
{ value: 'all', label: 'All filters pass' }, <Button
{ value: 'any', label: 'Any filter passes' }, key={value}
]} size='small'
/> variant={isSelected ? 'primary' : 'subtle'}
</label> aria-pressed={isSelected}
{fieldFilters.map((field, index) => { onClick={() => toggleCycle(value)}
const key = `filters.${index}.field.${field.id}`; >
return ( {label}
<div key={key} className={style.filterSection}> </Button>
<label> );
Runtime data source })}
<Select<string | null> </Panel.InlineElements>
// need to normalize '' to null for the Select to show the placeholder {hasContinuousCycle && (
value={watch(`filters.${index}.field`) || null} <Panel.Description tone='warning'>
onValueChange={(value) => { Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
if (value === null) return; mean to send on every tick.
setValue(`filters.${index}.field`, value, { shouldDirty: true }); </Panel.Description>
}} )}
options={fieldList.map(({ value, label }) => ({ </div>
value, </div>
label,
disabled: value === null, <div className={style.innerColumn}>
}))} <h3>Filters (optional)</h3>
aria-label='Event field' <Panel.Description>
/> Without filters the outputs are sent every time the automation is triggered.
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> </Panel.Description>
</label> <div className={style.ruleSection}>
<label> {fieldFilters.length > 1 && (
Matching condition <label>
<Select Trigger outputs if
value={watch(`filters.${index}.operator`)} <RadioGroup
onValueChange={(value: string | null) => { orientation='horizontal'
if (value === null) return; value={watch('filterRule')}
setValue( onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
`filters.${index}.operator`, items={[
value as { value: 'all', label: 'All filters pass' },
| 'equals' { value: 'any', label: 'Any filter passes' },
| 'not_equals' ]}
| 'greater_than' />
| 'less_than' </label>
| 'contains' )}
| 'not_contains', {fieldFilters.map((field, index) => {
{ shouldDirty: true }, const description = describeFilter(index);
); return (
}} <div key={field.id} className={style.card}>
options={[ <div className={style.cardHeader}>
{ value: 'equals', label: 'equals' }, <Tag>Filter</Tag>
{ value: 'not_equals', label: 'not equals' }, <span className={style.cardSummary}>{description}</span>
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton <IconButton
aria-label='Delete' aria-label='Delete filter'
variant='ghosted-destructive' variant='ghosted-destructive'
onClick={() => removeFilter(index)} onClick={() => removeFilter(index)}
> >
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</div> </div>
<div className={style.cardBody}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
shouldDirty: true,
});
}}
options={operators}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
</div>
</div> </div>
</div> );
); })}
})} <div>
<div> <Button onClick={handleAddNewFilter}>
<Button onClick={handleAddNewFilter}> Add filter <IoAdd />
Add filter <IoAdd /> </Button>
</Button> </div>
</div> </div>
</div> </div>
</div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
or to change properties of Ontime itself. <br /> <br /> <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} </Info>
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.length === 0 && (
if (isOSCOutput(output)) { <Panel.EmptyState
const rowErrors = errors.outputs?.[index] as title='This automation does nothing yet'
| { description='An automation without outputs will be triggered, but it has nothing to send.'
targetIP?: { message?: string }; />
targetPort?: { message?: string }; )}
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return ( {fieldOutputs.map((output, index) => {
<div key={output.id} className={style.outputCard}> const rowErrors = getOutputErrors(index);
<Tag>OSC</Tag> const cardProps = {
<div className={style.oscSection}> testState: testResults[output.id],
<label> onTest: () => handleTest(index, output.id),
Target IP onDelete: () => removeOutput(index),
<Input };
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isOntimeAction(output)) { if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as return (
| { <OutputCard
action?: { message?: string }; key={output.id}
time?: { message?: string }; label='OSC'
text?: { message?: string }; kindClass={style.tagOsc}
visible?: { message?: string }; summary={watch(`outputs.${index}.address`)}
secondarySource?: { message?: string }; unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
} {...cardProps}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
> >
<span>&nbsp;</span> <OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
<Panel.InlineElements relation='inner'> </OutputCard>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}> );
Test }
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
return null; if (isHTTPOutput(output)) {
})} return (
<Panel.InlineElements relation='inner'> <OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<Button onClick={handleAddNewOSCOutput}> <HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
OSC <IoAdd /> </OutputCard>
</Button> );
<Button onClick={handleAddNewHTTPOutput}> }
HTTP <IoAdd />
</Button> if (isOntimeAction(output)) {
<Button onClick={handleAddnewOntimeAction}> return (
Ontime action <IoAdd /> <OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
</Button> <OntimeActionForm
</Panel.InlineElements> value={output.action}
</div> index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
/>
</OutputCard>
);
}
return null;
})}
<div>
<DropdownMenu
render={<Button />}
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 <IoAdd />
</DropdownMenu>
</div>
</div>
</ScrollArea>
</form> </form>
} }
footerElements={ footerElements={
@@ -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<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
return (
<label className={style.spanFull}>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
);
}
@@ -1,10 +1,11 @@
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types'; 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 { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput'; import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps { interface OntimeActionFormProps {
index: number; index: number;
register: UseFormRegister<AutomationDTO>; register: UseFormRegister<AutomationDTO>;
rowErrors?: { rowErrors?: OutputErrors;
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action']; value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>; watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>; setValue: UseFormSetValue<AutomationDTO>;
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
setValue, setValue,
rowErrors, rowErrors,
value, value,
children,
watch, watch,
}: PropsWithChildren<OntimeActionFormProps>) { }: OntimeActionFormProps) {
const [selectedAction, setSelectedAction] = useState<string>(value); const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => { const handleSetAction = (value: OntimeActionKey) => {
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
}; };
return ( return (
<div className={style.actionSection}> <>
<label> <label>
Action Action
<Select <Select
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && ( {selectedAction === 'message-set' && (
<> <>
<label> <label className={style.spanFull}>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -127,7 +121,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <>
<label> <label className={style.spanFull}>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
</label> </label>
</> </>
)} )}
</>
<div className={style.test}>{children}</div>
</div>
); );
} }
@@ -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<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
return (
<>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
</>
);
}
@@ -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 (
<div className={style.card}>
<div className={style.cardHeader}>
<Tag className={kindClass}>{label}</Tag>
<span className={style.cardSummary}>{summary}</span>
{testState?.status === 'ok' && (
<span className={style.testOk}>
<IoCheckmark />
{testState.message}
</span>
)}
{unavailableReason ? (
<Tag variant='warning'>{unavailableReason}</Tag>
) : (
<Button variant='ghosted-white' onClick={onTest} loading={testState?.status === 'sending'}>
Test
</Button>
)}
<IconButton aria-label='Delete output' variant='ghosted-destructive' onClick={onDelete}>
<IoTrash />
</IconButton>
</div>
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
<div className={style.cardBody}>{children}</div>
</div>
);
}