mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-12 17:49:37 +00:00
feat(automation): improve automation output editing
Organize output editing into focused cards with validation, testing feedback, and lifecycle selection.
This commit is contained in:
+80
-48
@@ -1,13 +1,22 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.formScroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.outerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $ui-white;
|
||||
|
||||
// the shared modal body owns scrolling for this regular form modal
|
||||
min-height: 100%;
|
||||
padding-block: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
@@ -26,61 +35,84 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.titleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
.titleSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.titleSection,
|
||||
.ruleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
label,
|
||||
div {
|
||||
// we use the div as non-interactive placeholder for button cells
|
||||
// it needs to match the size of the label element
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
.card {
|
||||
label {
|
||||
display: block;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
grid-template-columns: 2fr 1fr 2fr auto;
|
||||
}
|
||||
|
||||
.oscSection {
|
||||
grid-template-columns: 9rem 5rem 3fr 4fr auto;
|
||||
}
|
||||
|
||||
.httpSection {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
|
||||
.test {
|
||||
grid-column: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.outputCard {
|
||||
/** shared shell for a single filter or output */
|
||||
.card {
|
||||
border: 1px solid $white-10;
|
||||
border-left: 0.25rem solid $gray-1200;
|
||||
padding-left: 0.5rem;
|
||||
border-radius: $component-border-radius-md;
|
||||
background-color: $black-10;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
|
||||
/** pushes the actions to the end of the header, and absorbs any overflow */
|
||||
.cardSummary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: $aux-text-size;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/** for fields that read badly when narrow: OSC address and args, URLs, message text */
|
||||
.spanFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.testOk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $green-400;
|
||||
}
|
||||
|
||||
.testError {
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.tagOsc {
|
||||
background-color: $blue-1000;
|
||||
color: $blue-300;
|
||||
}
|
||||
|
||||
.tagHttp {
|
||||
background-color: $green-1000;
|
||||
color: $green-300;
|
||||
}
|
||||
|
||||
.tagOntime {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
+243
-257
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
HTTPOutput,
|
||||
OSCOutput,
|
||||
OntimeAction,
|
||||
AutomationFilter,
|
||||
TimerLifeCycle,
|
||||
Trigger,
|
||||
isHTTPOutput,
|
||||
isOSCOutput,
|
||||
isOntimeAction,
|
||||
} from 'ontime-types';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
|
||||
@@ -16,38 +16,70 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils';
|
||||
import HttpOutputForm from './HttpOutputForm';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
import OscOutputForm from './OscOutputForm';
|
||||
import OutputCard, { type TestState } from './OutputCard';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
const formId = 'automation-form';
|
||||
|
||||
/** how long a successful test keeps its confirmation on screen */
|
||||
const testFeedbackDuration = 2000;
|
||||
|
||||
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
|
||||
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
triggers?: Trigger[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
|
||||
export default function AutomationForm({ automation, triggers = [], onClose }: AutomationFormProps) {
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
|
||||
const [initialCycles] = useState<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 {
|
||||
clearErrors,
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
@@ -60,10 +92,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
} = useForm<AutomationDTO>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
title: automation?.title ?? '',
|
||||
filterRule: automation?.filterRule ?? 'all',
|
||||
filters: automation?.filters ?? [],
|
||||
outputs: automation?.outputs ?? [],
|
||||
title: automation.title,
|
||||
filterRule: automation.filterRule,
|
||||
filters: automation.filters,
|
||||
outputs: automation.outputs,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
@@ -88,11 +120,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
control,
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = feedbackTimers.current;
|
||||
return () => Object.values(timers).forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
const reportTest = (key: string, state: TestState) => {
|
||||
setTestResults((prev) => ({ ...prev, [key]: state }));
|
||||
clearTimeout(feedbackTimers.current[key]);
|
||||
|
||||
if (state.status === 'ok') {
|
||||
feedbackTimers.current[key] = setTimeout(() => {
|
||||
setTestResults((prev) => {
|
||||
const { [key]: _discarded, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, testFeedbackDuration);
|
||||
}
|
||||
};
|
||||
|
||||
const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
|
||||
|
||||
const handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
@@ -106,84 +158,86 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
appendOutput({ type: 'http', url: '' });
|
||||
};
|
||||
|
||||
const handleAddnewOntimeAction = () => {
|
||||
const handleAddNewOntimeAction = () => {
|
||||
appendOutput({ type: 'ontime', action: 'aux1-start' });
|
||||
};
|
||||
|
||||
const handleTestOSCOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as HTTPOutput;
|
||||
if (!values.url) {
|
||||
if (isHTTPOutput(values) && !values.url) {
|
||||
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
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 {
|
||||
const values = getValues(`outputs.${index}`) as OntimeAction;
|
||||
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
|
||||
await testOutput({
|
||||
...values,
|
||||
type: 'ontime',
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here */
|
||||
await testOutput(values);
|
||||
reportTest(key, { status: 'ok', message: 'Request sent' });
|
||||
} catch (error) {
|
||||
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
} else {
|
||||
await handleCreate(values);
|
||||
}
|
||||
// a stale failure from the previous attempt would otherwise sit under a successful retry
|
||||
clearErrors('root');
|
||||
|
||||
try {
|
||||
if (!isAutomation(automation)) {
|
||||
await addAutomation(
|
||||
values,
|
||||
selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })),
|
||||
);
|
||||
refetch();
|
||||
|
||||
async function handleEdit(id: string, values: Automation) {
|
||||
try {
|
||||
await editAutomation(id, values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function handleCreate(values: AutomationDTO) {
|
||||
try {
|
||||
await addAutomation(values);
|
||||
onClose();
|
||||
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();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
const describeFilter = (index: number): string | null => {
|
||||
const field = watch(`filters.${index}.field`);
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field;
|
||||
const operator = watch(`filters.${index}.operator`);
|
||||
const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator;
|
||||
const value = watch(`filters.${index}.value`);
|
||||
|
||||
return `${fieldLabel} ${operatorLabel} ${value ? `“${value}”` : 'nothing'}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A failed save reports itself as a root error, which react-hook-form counts against
|
||||
* isValid. Left alone that disables the very retry the message is asking the user to make,
|
||||
* so a root error on its own does not block submitting again.
|
||||
*/
|
||||
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
|
||||
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0);
|
||||
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -191,9 +245,11 @@ 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}>
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
@@ -207,11 +263,45 @@ 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(({ label, value }) => {
|
||||
const isSelected = selectedCycles.includes(value);
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
size='small'
|
||||
variant={isSelected ? 'primary' : 'subtle'}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCycle(value)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</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}>
|
||||
{fieldFilters.length > 1 && (
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
@@ -224,10 +314,23 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
const description = describeFilter(index);
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<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>
|
||||
@@ -252,23 +355,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
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 },
|
||||
);
|
||||
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
]}
|
||||
options={operators}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
@@ -277,17 +368,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -303,152 +383,51 @@ 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.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
targetIP?: { message?: string };
|
||||
targetPort?: { message?: string };
|
||||
address?: { message?: string };
|
||||
args?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
{fieldOutputs.length === 0 && (
|
||||
<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) => {
|
||||
const rowErrors = getOutputErrors(index);
|
||||
const cardProps = {
|
||||
testState: testResults[output.id],
|
||||
onTest: () => handleTest(index, output.id),
|
||||
onDelete: () => removeOutput(index),
|
||||
};
|
||||
|
||||
if (isOSCOutput(output)) {
|
||||
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)}
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
|
||||
{...cardProps}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
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> </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} {...cardProps}>
|
||||
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
@@ -456,39 +435,46 @@ 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={[
|
||||
...(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>
|
||||
}
|
||||
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 { PropsWithChildren, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
|
||||
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import type { OutputErrors } from './automationUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
|
||||
interface OntimeActionFormProps {
|
||||
index: number;
|
||||
register: UseFormRegister<AutomationDTO>;
|
||||
rowErrors?: {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
};
|
||||
rowErrors?: OutputErrors;
|
||||
value: OntimeAction['action'];
|
||||
watch: UseFormWatch<AutomationDTO>;
|
||||
setValue: UseFormSetValue<AutomationDTO>;
|
||||
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
|
||||
setValue,
|
||||
rowErrors,
|
||||
value,
|
||||
children,
|
||||
watch,
|
||||
}: PropsWithChildren<OntimeActionFormProps>) {
|
||||
}: OntimeActionFormProps) {
|
||||
const [selectedAction, setSelectedAction] = useState<string>(value);
|
||||
|
||||
const handleSetAction = (value: OntimeActionKey) => {
|
||||
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.actionSection}>
|
||||
<>
|
||||
<label>
|
||||
Action
|
||||
<Select
|
||||
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
|
||||
|
||||
{selectedAction === 'message-set' && (
|
||||
<>
|
||||
<label>
|
||||
<label className={style.spanFull}>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
@@ -127,7 +121,7 @@ export default function OntimeActionForm({
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<>
|
||||
<label>
|
||||
<label className={style.spanFull}>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user