mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 16:49:41 +00:00
feat(automation): one step creation, recipes, and a panel that says what it does
Making an automation took two visits to two lists. You wrote the automation in one, then remembered that an automation alone never runs, went to the trigger list, made a trigger, and pointed it back at what you had just made. A first time user who stopped after the first step got a thing that looked finished and did nothing. Lifecycles are now picked on the automation form, as chips. Saving reconciles the triggers behind it, diffed against a snapshot taken when the form opened so a save never removes a trigger the user could not see. Every lifecycle is in one place, including the two that fire continuously, which say so before you pick them rather than after your log fills up. That leaves the trigger list as what it actually is: a place to rename a generated trigger, or to point several differently named ones at the same automation. It says so, and it names the triggers whose automation is gone. New opens one list of starting points: an empty automation, then a handful of recipes for the software people actually pair Ontime with. A recipe is nothing but a pre-filled form, so choosing one opens the ordinary automation form with its values in place. The user reads what it will do, points it at their own gear and saves. Nothing is written until they do, and there is no second creation path to keep working: one request, the same as any other automation. Every recipe targets loopback, so one saved without thinking cannot put traffic on a venue network. The list now answers what an automation does without opening it: when it runs, whether it filters, and what it sends. An automation with no trigger or no output is the common half-finished state and is called out rather than shown as a blank cell. Outputs are cards with their type, a summary and a Test button in the header, which is also where the test says whether it worked: the panel used to send and tell the user nothing either way. Deleting confirms first and names the triggers that go with it, instead of dumping the server's refusal into a stray row under the table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
This commit is contained in:
+440
-277
@@ -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<Trigger[]>(() =>
|
||||
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<TimerLifeCycle[]>(
|
||||
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<string | null>(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<Record<string, TestState>>({});
|
||||
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
@@ -191,6 +294,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='wide'
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
@@ -207,87 +311,119 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
|
||||
<div className={style.titleSection}>
|
||||
<label id='runs-on-label'>Runs on</label>
|
||||
<Panel.Description>
|
||||
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.
|
||||
</Panel.Description>
|
||||
<Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
|
||||
{cycles.map(({ id, label, value }) => {
|
||||
const cycle = value as TimerLifeCycle;
|
||||
const isSelected = selectedCycles.includes(cycle);
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
size='small'
|
||||
variant={isSelected ? 'primary' : 'subtle'}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCycle(cycle)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Panel.InlineElements>
|
||||
{hasContinuousCycle && (
|
||||
<Panel.Description tone='warning'>
|
||||
Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you mean
|
||||
to send on every tick.
|
||||
</Panel.Description>
|
||||
)}
|
||||
{triggersToRemove.length > 0 && (
|
||||
<Panel.Description tone='warning'>
|
||||
{`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove
|
||||
.map((trigger) => trigger.title)
|
||||
.join(', ')}`}
|
||||
</Panel.Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<Panel.Description>
|
||||
Without filters the outputs are sent every time the automation is triggered.
|
||||
</Panel.Description>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.length > 1 && (
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
const description = describeFilter(index);
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<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
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'contains'
|
||||
| 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ 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> </span>
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div key={field.id} className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag>Filter</Tag>
|
||||
<span className={style.cardSummary}>{description}</span>
|
||||
<IconButton
|
||||
aria-label='Delete filter'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</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>
|
||||
);
|
||||
@@ -303,12 +439,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
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.{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='This automation does nothing yet'
|
||||
description='An automation without outputs will be triggered, but it has nothing to send.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<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>
|
||||
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> </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>
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
testState={testResults[output.id]}
|
||||
onTest={() => handleTest(index, output.id)}
|
||||
onDelete={() => removeOutput(index)}
|
||||
>
|
||||
<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>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
@@ -397,42 +524,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
}
|
||||
| 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> </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>
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='HTTP'
|
||||
kindClass={style.tagHttp}
|
||||
testState={testResults[output.id]}
|
||||
onTest={() => handleTest(index, output.id)}
|
||||
onDelete={() => removeOutput(index)}
|
||||
>
|
||||
<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>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -447,8 +563,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='Ontime action'
|
||||
kindClass={style.tagOntime}
|
||||
testState={testResults[output.id]}
|
||||
onTest={() => handleTest(index, output.id)}
|
||||
onDelete={() => removeOutput(index)}
|
||||
>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
@@ -456,38 +578,40 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<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>
|
||||
/>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={handleAddNewOSCOutput}>
|
||||
OSC <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddNewHTTPOutput}>
|
||||
HTTP <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddnewOntimeAction}>
|
||||
Ontime action <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
<div>
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
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 <IoAdd />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user