mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-07 07:19:16 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c361791080 | |||
| 126abcbd93 | |||
| 2ddd496c78 | |||
| 25eb68452c | |||
| a905bf912f | |||
| 149cc0da92 | |||
| 419ca5c4ca |
@@ -0,0 +1,25 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* User facing labels for the timer lifecycle
|
||||
* Shared between the automation settings and the rundown event editor
|
||||
* so that a lifecycle is named the same everywhere it is shown
|
||||
*/
|
||||
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
|
||||
[TimerLifeCycle.onLoad]: 'On Load',
|
||||
[TimerLifeCycle.onStart]: 'On Start',
|
||||
[TimerLifeCycle.onPause]: 'On Pause',
|
||||
[TimerLifeCycle.onStop]: 'On Stop',
|
||||
[TimerLifeCycle.onClock]: 'Every second',
|
||||
[TimerLifeCycle.onUpdate]: 'On Timer Update',
|
||||
[TimerLifeCycle.onFinish]: 'On Finish',
|
||||
[TimerLifeCycle.onWarning]: 'On Warning',
|
||||
[TimerLifeCycle.onDanger]: 'On Danger',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a lifecycle to its user facing label, falling back to the raw value
|
||||
*/
|
||||
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
|
||||
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AutomationOutput } from 'ontime-types';
|
||||
|
||||
import { summariseOutputs } from '../automationOutputs';
|
||||
|
||||
describe('summariseOutputs', () => {
|
||||
it('returns an empty list when there are no outputs', () => {
|
||||
expect(summariseOutputs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts repeated output kinds', () => {
|
||||
const outputs: AutomationOutput[] = [
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
|
||||
{ type: 'http', url: 'http://127.0.0.1/start' },
|
||||
];
|
||||
|
||||
expect(summariseOutputs(outputs)).toEqual([
|
||||
{ type: 'osc', label: 'OSC', count: 2 },
|
||||
{ type: 'http', label: 'HTTP', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('presents kinds in a stable order regardless of insertion order', () => {
|
||||
const outputs: AutomationOutput[] = [
|
||||
{ type: 'ontime', action: 'aux1-start' },
|
||||
{ type: 'http', url: 'http://127.0.0.1/start' },
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
|
||||
];
|
||||
|
||||
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AutomationOutput } from 'ontime-types';
|
||||
|
||||
const outputLabels: Record<AutomationOutput['type'], string> = {
|
||||
osc: 'OSC',
|
||||
http: 'HTTP',
|
||||
ontime: 'Ontime',
|
||||
};
|
||||
|
||||
export type OutputSummary = {
|
||||
type: AutomationOutput['type'];
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Summarises an automation's outputs by kind so that a list row can say what the
|
||||
* automation does without the user having to open the form.
|
||||
* Shared between the automation settings panel and the rundown event editor.
|
||||
*/
|
||||
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
|
||||
const counts = new Map<AutomationOutput['type'], number>();
|
||||
|
||||
for (const output of outputs) {
|
||||
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// keep a stable presentation order regardless of the order the user added outputs
|
||||
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
|
||||
return order
|
||||
.filter((type) => counts.has(type))
|
||||
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
|
||||
}
|
||||
+85
-48
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* The wide modal body does not scroll, so the form owns it.
|
||||
* Without this the form is simply clipped: four outputs is enough to put Save out of reach.
|
||||
*/
|
||||
.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;
|
||||
// leaves the overlay scrollbar somewhere to sit without covering a field
|
||||
padding-right: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
@@ -26,61 +40,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;
|
||||
}
|
||||
|
||||
+381
-348
@@ -1,52 +1,109 @@
|
||||
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';
|
||||
|
||||
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';
|
||||
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 * 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;
|
||||
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
|
||||
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]);
|
||||
|
||||
/**
|
||||
* 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],
|
||||
);
|
||||
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles);
|
||||
/** 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 +150,28 @@ 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 getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
|
||||
|
||||
const handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
@@ -110,80 +189,99 @@ 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) {
|
||||
await addTrigger({ title: makeTriggerTitle(title, cycle), 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'}`;
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && isValid;
|
||||
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -191,304 +289,239 @@ 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}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<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.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
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>
|
||||
<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}>
|
||||
{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 description = describeFilter(index);
|
||||
return (
|
||||
<div key={field.id} className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag>Filter</Tag>
|
||||
<span className={style.cardSummary}>{description}</span>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
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.'
|
||||
/>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
{fieldOutputs.map((output, index) => {
|
||||
const rowErrors = getOutputErrors(index);
|
||||
const cardProps = {
|
||||
testState: testResults[output.id],
|
||||
onTest: () => handleTest(index, output.id),
|
||||
onDelete: () => removeOutput(index),
|
||||
};
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<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}
|
||||
if (isOSCOutput(output)) {
|
||||
return (
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
{...cardProps}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</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>
|
||||
if (isHTTPOutput(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
|
||||
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
|
||||
@@ -30,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList
|
||||
triggers={data.triggers}
|
||||
<AutomationsList
|
||||
automations={data.automations}
|
||||
triggers={data.triggers}
|
||||
enabledAutomations={automationState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
|
||||
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/** tags make a cell taller than the title beside it, which staggers on the default baseline */
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $muted-gray;
|
||||
}
|
||||
+129
-58
@@ -1,18 +1,25 @@
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { deleteAutomation } 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 Info from '../../../../common/components/info/Info';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import AutomationForm from './AutomationForm';
|
||||
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
|
||||
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||
import NewAutomationDialog from './NewAutomationDialog';
|
||||
|
||||
const automationPlaceholder: AutomationDTO = {
|
||||
import style from './AutomationsList.module.scss';
|
||||
|
||||
const emptyAutomation: AutomationDTO = {
|
||||
title: '',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
@@ -21,37 +28,73 @@ const automationPlaceholder: AutomationDTO = {
|
||||
|
||||
interface AutomationsListProps {
|
||||
automations: NormalisedAutomation;
|
||||
triggers: Trigger[];
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||
export default function AutomationsList({
|
||||
automations,
|
||||
triggers,
|
||||
enabledAutomations,
|
||||
isLoading,
|
||||
}: AutomationsListProps) {
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
|
||||
const [isPickingStart, setIsPickingStart] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteAutomation(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
|
||||
const automationIds = Object.keys(automations);
|
||||
|
||||
/** a recipe creates the automation itself, so it lands in the list rather than in a form */
|
||||
const handleCreated = async () => {
|
||||
setIsPickingStart(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
const handleStartEmpty = () => {
|
||||
setIsPickingStart(false);
|
||||
setEditing(emptyAutomation);
|
||||
};
|
||||
|
||||
const handleDeleted = async () => {
|
||||
setDeleteTarget(null);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
{editing !== null && (
|
||||
<AutomationForm
|
||||
// the form snapshots the automation's lifecycles on mount, so it must never be
|
||||
// reused across two different automations
|
||||
key={isAutomation(editing) ? editing.id : 'new'}
|
||||
automation={editing}
|
||||
triggers={triggers}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{isPickingStart && (
|
||||
<NewAutomationDialog
|
||||
onClose={() => setIsPickingStart(false)}
|
||||
onStartEmpty={handleStartEmpty}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget !== null && (
|
||||
<DeleteAutomationDialog
|
||||
automation={deleteTarget}
|
||||
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
<Button onClick={() => setIsPickingStart(true)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -60,74 +103,102 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||
enabled.
|
||||
<Info type='warning'>
|
||||
<Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
|
||||
<Info.Footer>
|
||||
{/* the master switch is at the top of the panel, out of sight once the list has rows */}
|
||||
<Button size='small' onClick={() => setLocation('automation__settings')}>
|
||||
Go to automation settings
|
||||
</Button>
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<Panel.Table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Runs on</th>
|
||||
<th style={{ width: '15%' }}>Filter rule</th>
|
||||
<th style={{ width: '15%' }}>Sends</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!isLoading && arrayAutomations.length === 0 && (
|
||||
{!isLoading && automationIds.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No automations yet'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create automation <IoAdd />
|
||||
<Button variant='primary' onClick={() => setIsPickingStart(true)}>
|
||||
New automation <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
{automationIds.map((automationId) => {
|
||||
const automation = automations[automationId];
|
||||
const lifecycles = lifecyclesByAutomation[automationId] ?? [];
|
||||
const outputs = summariseOutputs(automation.outputs);
|
||||
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
<tr>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<tr key={automationId}>
|
||||
<td>{automation.title}</td>
|
||||
<td>
|
||||
{/*
|
||||
* Only global triggers are visible here: an automation can also be attached to
|
||||
* single events, which live in the rundown. An empty cell is therefore not the
|
||||
* same as never running, so it says nothing rather than claiming that.
|
||||
*/}
|
||||
{lifecycles.length === 0 ? (
|
||||
<span className={style.muted}>—</span>
|
||||
) : (
|
||||
<div className={style.tags}>
|
||||
{lifecycles.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{automation.filters.length === 0 ? (
|
||||
<span className={style.muted}>—</span>
|
||||
) : (
|
||||
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className={style.tags}>
|
||||
{outputs.length === 0 ? (
|
||||
<Tag variant='warning'>No outputs</Tag>
|
||||
) : (
|
||||
outputs.map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={cx([style.tags, style.actions])}>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
onClick={() => setEditing(automation)}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
onClick={() => setDeleteTarget(automation)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
</Fragment>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { Automation, Trigger } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
interface DeleteAutomationDialogProps {
|
||||
automation: Automation;
|
||||
/** global triggers pointing at this automation, they are deleted along with it */
|
||||
attachedTriggers: Trigger[];
|
||||
onCancel: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting takes the automation's global triggers with it, so say so before it happens rather
|
||||
* than leaving the user to discover it in the triggers list.
|
||||
*
|
||||
* An automation attached to an event is still refused by the server: that reference lives in
|
||||
* the rundown and removing it is an edit to the show, not to this panel.
|
||||
*/
|
||||
export default function DeleteAutomationDialog({
|
||||
automation,
|
||||
attachedTriggers,
|
||||
onCancel,
|
||||
onDeleted,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAutomation(automation.id);
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Delete automation'
|
||||
bodyElements={
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>
|
||||
Delete <strong>{automation.title}</strong>? This cannot be undone.
|
||||
</Panel.Paragraph>
|
||||
|
||||
{attachedTriggers.length > 0 && (
|
||||
<Info type='warning'>
|
||||
<Info.Title>
|
||||
{attachedTriggers.length === 1
|
||||
? 'Its trigger is deleted with it'
|
||||
: `Its ${attachedTriggers.length} triggers are deleted with it`}
|
||||
</Info.Title>
|
||||
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
|
||||
</Info>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Info type='error'>
|
||||
<Info.Title>Could not delete this automation</Info.Title>
|
||||
<Info.Body>{error}</Info.Body>
|
||||
<Info.Footer>
|
||||
An automation attached to a single event has to be removed from that event first, in the event editor.
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
</Panel.Section>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/* ---------- step one: pick a recipe ---------- */
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
/** outside the scrolling list, so it stays put however many recipes there are */
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the list rather than fixing its height, so the dialog still shrinks to two results
|
||||
* when a search narrows it down.
|
||||
*/
|
||||
.listViewport {
|
||||
height: auto;
|
||||
max-height: min(52vh, 30rem);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// room for the overlay scrollbar beside the chevrons
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
color: $gray-400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin: 0;
|
||||
padding-inline: 0.125rem;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.recipe {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $white-3;
|
||||
border-color: $white-10;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid $action-blue;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.recipeText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipeTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipeDescription {
|
||||
font-size: $aux-text-size;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.recipeTags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
/* ---------- step two: answer what the recipe cannot know ---------- */
|
||||
|
||||
.setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
.setupDescription {
|
||||
margin: 0;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
/** the two facts a recipe decides for you, stated before the fields you can change */
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background-color: $black-10;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
dt {
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
dd {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* three columns, so a recipe's small numeric fields fill a row instead of leaving a hole */
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (width < 40rem) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* an address or a sentence, which a third of a row cannot hold */
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
/* pushes the leading action away from the confirming ones */
|
||||
.apart {
|
||||
margin-right: auto;
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
import type { Automation } from 'ontime-types';
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react';
|
||||
import { IoAdd, IoArrowBack, IoChevronForward, IoClose, IoSearch } from 'react-icons/io5';
|
||||
|
||||
import { addAutomation, addTrigger } 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 Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import {
|
||||
automationRecipes,
|
||||
defaultValues,
|
||||
needsTarget,
|
||||
recipeCategoryLabels,
|
||||
recipeCategoryOrder,
|
||||
type AutomationRecipe,
|
||||
type RecipeValues,
|
||||
} from './automationRecipes';
|
||||
import { makeTriggerTitle } from './automationUtils';
|
||||
|
||||
import style from './NewAutomationDialog.module.scss';
|
||||
|
||||
interface NewAutomationDialogProps {
|
||||
onClose: () => void;
|
||||
/** hands over to the full automation form for someone who wants to start empty */
|
||||
onStartEmpty: () => void;
|
||||
onCreated: (automation: Automation) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single entry point for making an automation.
|
||||
*
|
||||
* Two steps in one dialog rather than two stacked ones: pick a recipe, then answer only
|
||||
* what that recipe cannot know — where your gear is, how long the timer runs. Everything
|
||||
* else the recipe already decided, which is the point of having recipes at all.
|
||||
*/
|
||||
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
|
||||
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
|
||||
|
||||
return selected === null ? (
|
||||
<RecipePicker onClose={onClose} onStartEmpty={onStartEmpty} onSelect={setSelected} />
|
||||
) : (
|
||||
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
|
||||
);
|
||||
}
|
||||
|
||||
/** matches on everything the user might type: the software, its protocol, the job it does */
|
||||
function matches(recipe: AutomationRecipe, query: string): boolean {
|
||||
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
interface RecipePickerProps {
|
||||
onClose: () => void;
|
||||
onStartEmpty: () => void;
|
||||
onSelect: (recipe: AutomationRecipe) => void;
|
||||
}
|
||||
|
||||
function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const available = useMemo(
|
||||
() =>
|
||||
// OSC is not available in the cloud service, offering those recipes there would be a lie
|
||||
isOntimeCloud
|
||||
? automationRecipes.filter(
|
||||
(recipe) => !recipe.build(defaultValues(recipe)).outputs.some((output) => output.type === 'osc'),
|
||||
)
|
||||
: automationRecipes,
|
||||
[],
|
||||
);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const results = trimmed ? available.filter((recipe) => matches(recipe, trimmed)) : available;
|
||||
|
||||
const handleSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// the dialog is the only thing listening for escape, and losing it while clearing a
|
||||
// search would be a bigger surprise than the search staying put
|
||||
if (event.key === 'Escape' && trimmed.length > 0) {
|
||||
event.stopPropagation();
|
||||
setQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && results.length > 0) {
|
||||
onSelect(results[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title='New automation'
|
||||
bodyElements={
|
||||
<div className={style.picker}>
|
||||
<div className={style.search}>
|
||||
<IoSearch className={style.searchIcon} />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKey}
|
||||
placeholder='Search recipes, eg. QLab, OSC, message'
|
||||
className={style.searchInput}
|
||||
aria-label='Search recipes'
|
||||
fluid
|
||||
autoFocus
|
||||
/>
|
||||
{trimmed.length > 0 && (
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
aria-label='Clear search'
|
||||
className={style.searchClear}
|
||||
onClick={() => setQuery('')}
|
||||
>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='No recipe matches that'
|
||||
description='Try the name of the software, or start from an empty automation.'
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollArea viewportClassName={style.listViewport} contentClassName={style.list}>
|
||||
{recipeCategoryOrder.map((category) => {
|
||||
const inCategory = results.filter((recipe) => recipe.category === category);
|
||||
if (inCategory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={category} className={style.group}>
|
||||
<h4 className={style.groupTitle}>{recipeCategoryLabels[category]}</h4>
|
||||
{inCategory.map((recipe) => (
|
||||
<button type='button' key={recipe.id} className={style.recipe} onClick={() => onSelect(recipe)}>
|
||||
<div className={style.recipeText}>
|
||||
<div className={style.recipeTitle}>{recipe.title}</div>
|
||||
<div className={style.recipeDescription}>{recipe.description}</div>
|
||||
</div>
|
||||
<div className={style.recipeTags}>
|
||||
{recipe.triggers.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
<IoChevronForward className={style.chevron} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button variant='ghosted-white' className={style.apart} onClick={onStartEmpty}>
|
||||
Start from an empty automation
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecipeSetupProps {
|
||||
recipe: AutomationRecipe;
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
onCreated: (automation: Automation) => void;
|
||||
}
|
||||
|
||||
function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
|
||||
const [values, setValues] = useState<RecipeValues>(() => defaultValues(recipe));
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const automation = recipe.build(values);
|
||||
const isComplete = recipe.params.every(({ name }) => values[name]?.trim());
|
||||
|
||||
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError(null);
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// the same two steps the automation form takes when it saves a new automation:
|
||||
// the server generates the id, so the automation has to exist before a trigger can point at it
|
||||
const created = await addAutomation(automation);
|
||||
for (const cycle of recipe.triggers) {
|
||||
await addTrigger({
|
||||
title: makeTriggerTitle(automation.title, cycle),
|
||||
trigger: cycle,
|
||||
automationId: created.id,
|
||||
});
|
||||
}
|
||||
onCreated(created);
|
||||
} catch (error) {
|
||||
// a half created automation is visible in the list and flagged there, so say what
|
||||
// happened and let the user finish it in the form rather than undoing their work
|
||||
setError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title={recipe.title}
|
||||
bodyElements={
|
||||
<div className={style.setup}>
|
||||
<p className={style.setupDescription}>{recipe.description}</p>
|
||||
|
||||
<dl className={style.summary}>
|
||||
<dt>Runs on</dt>
|
||||
<dd>
|
||||
{recipe.triggers.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
<dt>Sends</dt>
|
||||
<dd>
|
||||
{summariseOutputs(automation.outputs).map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{recipe.params.length > 0 && (
|
||||
<div className={style.fields}>
|
||||
{recipe.params.map((param) => (
|
||||
<label key={param.name} className={cx([style.field, param.wide && style.wide])}>
|
||||
{param.label}
|
||||
{param.type === 'choice' ? (
|
||||
<Select
|
||||
value={values[param.name]}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(param.name, value);
|
||||
}}
|
||||
options={param.options ?? []}
|
||||
aria-label={param.label}
|
||||
fluid
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
value={values[param.name]}
|
||||
onChange={(event) => setValue(param.name, event.target.value)}
|
||||
fluid
|
||||
/>
|
||||
)}
|
||||
{param.hint && <span className={style.hint}>{param.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Panel.Description>
|
||||
{needsTarget(recipe)
|
||||
? 'Created as a normal automation. Nothing is sent until an event triggers it.'
|
||||
: 'Created as a normal automation, which you can edit or delete like any other.'}
|
||||
</Panel.Description>
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Button variant='ghosted-white' className={style.apart} onClick={onBack} disabled={isCreating}>
|
||||
<IoArrowBack /> All recipes
|
||||
</Button>
|
||||
<Button onClick={onClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleCreate} loading={isCreating} disabled={!isComplete}>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,58 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { IoCheckmark, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
|
||||
|
||||
interface OutputCardProps {
|
||||
label: string;
|
||||
kindClass?: string;
|
||||
summary?: string;
|
||||
testState?: TestState;
|
||||
onTest: () => void;
|
||||
onDelete: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for every output kind: the type tag and the actions live in the header,
|
||||
* so they stop competing with the form fields for grid columns
|
||||
*/
|
||||
export default function OutputCard({
|
||||
label,
|
||||
kindClass,
|
||||
summary,
|
||||
testState,
|
||||
onTest,
|
||||
onDelete,
|
||||
children,
|
||||
}: OutputCardProps) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import { IoAdd } from 'react-icons/io5';
|
||||
import { deleteTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import TriggerForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
@@ -20,13 +20,13 @@ type FormState = {
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
|
||||
export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
||||
const { refetch } = useAutomationSettings();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
@@ -50,6 +50,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
};
|
||||
|
||||
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
|
||||
const orphans = useMemo(
|
||||
() => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length,
|
||||
[triggers, automations],
|
||||
);
|
||||
|
||||
// there is no point letting user creating a trigger if there are no automations
|
||||
const canAdd = Object.keys(automations).length > 0;
|
||||
@@ -66,22 +70,28 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
Global triggers
|
||||
<Button disabled={!canAdd} onClick={openNewForm}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
</Info>
|
||||
)}
|
||||
<Panel.Description>
|
||||
Triggers are managed from the automation itself. This list is for naming them, or for pointing several
|
||||
differently named triggers at the same automation.
|
||||
</Panel.Description>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can cause performance
|
||||
issues.
|
||||
You have created multiple links between the same trigger and automation. Duplicate combinations will only
|
||||
fire once per lifecycle event.
|
||||
</Panel.Error>
|
||||
)}
|
||||
{orphans > 0 && (
|
||||
<Panel.Error>
|
||||
{orphans === 1
|
||||
? '1 trigger points at an automation that no longer exists and will never run.'
|
||||
: `${orphans} triggers point at automations that no longer exist and will never run.`}
|
||||
</Panel.Error>
|
||||
)}
|
||||
<Panel.Table>
|
||||
@@ -99,14 +109,18 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
title='No triggers yet'
|
||||
description={
|
||||
canAdd
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||
: 'Create an automation first, then add a trigger to decide when it should run.'
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.'
|
||||
: 'Create an automation first, then pick the lifecycles it should run on.'
|
||||
}
|
||||
action={
|
||||
canAdd && (
|
||||
canAdd ? (
|
||||
<Button variant='primary' onClick={openNewForm}>
|
||||
Create trigger <IoAdd />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='primary' onClick={() => setLocation('automation__automations')}>
|
||||
Go to automations
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,12 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
||||
{/* a trigger can outlive the automation it points at, say after a partial project import */}
|
||||
{automations?.[trigger.automationId] ? (
|
||||
<Tag>{automations[trigger.automationId].title}</Tag>
|
||||
) : (
|
||||
<Tag variant='warning'>Missing automation</Tag>
|
||||
)}
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
|
||||
|
||||
import { automationRecipes, defaultValues, needsTarget, recipeCategoryOrder } from '../automationRecipes';
|
||||
import { operators } from '../automationUtils';
|
||||
|
||||
/**
|
||||
* Recipes are shipped as constants but created through the same endpoint as a hand written
|
||||
* automation. These assertions stand in for the server side validation, so a recipe cannot
|
||||
* silently rot into something that 400s when the user presses create.
|
||||
*/
|
||||
describe('automationRecipes', () => {
|
||||
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = automationRecipes.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('only uses categories the picker knows how to render', () => {
|
||||
for (const { recipe } of built) {
|
||||
expect(recipeCategoryOrder).toContain(recipe.category);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds every recipe to at least one valid lifecycle', () => {
|
||||
for (const { recipe } of built) {
|
||||
expect(recipe.triggers.length).toBeGreaterThan(0);
|
||||
for (const cycle of recipe.triggers) {
|
||||
expect(timerLifecycleValues).toContain(cycle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a titled automation with something to send, from its own defaults', () => {
|
||||
for (const { automation } of built) {
|
||||
expect(automation.title).not.toBe('');
|
||||
expect(automation.outputs.length).toBeGreaterThan(0);
|
||||
|
||||
for (const output of automation.outputs) {
|
||||
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every choice parameter options, and a default that is one of them', () => {
|
||||
const choices = automationRecipes.flatMap(({ params }) => params.filter(({ type }) => type === 'choice'));
|
||||
expect(choices.filter(({ options }) => !options?.length)).toEqual([]);
|
||||
expect(choices.filter(({ options, defaultValue }) => !options?.some((o) => o.value === defaultValue))).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads every parameter it declares', () => {
|
||||
// a param the builder ignores is a field the user fills in for nothing, and a typo in
|
||||
// either half would put the literal 'undefined' inside a URL
|
||||
for (const { recipe } of built) {
|
||||
for (const param of recipe.params) {
|
||||
// a choice can only take one of its own options, so probe with the last one
|
||||
if (param.type === 'choice') {
|
||||
const last = param.options?.at(-1)?.value ?? '';
|
||||
expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last);
|
||||
continue;
|
||||
}
|
||||
const marker = param.type === 'number' ? '4242' : 'ontime-probe';
|
||||
const probed = { ...defaultValues(recipe), [param.name]: marker };
|
||||
expect(JSON.stringify(recipe.build(probed))).toContain(marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('only uses filter operators the server accepts', () => {
|
||||
const allowed = operators.map(({ value }) => value);
|
||||
for (const { automation } of built) {
|
||||
for (const filter of automation.filters) {
|
||||
expect(allowed).toContain(filter.operator);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults every external target to this machine', () => {
|
||||
const outputs = built.flatMap(({ automation }) => automation.outputs);
|
||||
const osc = outputs.filter(isOSCOutput);
|
||||
const http = outputs.filter(isHTTPOutput);
|
||||
|
||||
// filtering rather than asserting in a branch, so a failure names the offending recipe
|
||||
expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]);
|
||||
expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]);
|
||||
expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags the recipes that reach outside Ontime', () => {
|
||||
for (const { recipe, automation } of built) {
|
||||
const reachesOut = automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
|
||||
expect(needsTarget(recipe)).toBe(reachesOut);
|
||||
}
|
||||
});
|
||||
|
||||
/** the outputs a recipe builds from the given answers, as plain JSON to assert against */
|
||||
function buildWith(id: string, values: Record<string, string>) {
|
||||
const recipe = automationRecipes.find((candidate) => candidate.id === id);
|
||||
return JSON.stringify(recipe?.build(values).outputs);
|
||||
}
|
||||
|
||||
it('tolerates a URL that already carries a query', () => {
|
||||
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
|
||||
'/now?source=ontime&title=',
|
||||
);
|
||||
});
|
||||
|
||||
it('tolerates an address pasted with a trailing slash', () => {
|
||||
expect(
|
||||
buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
|
||||
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
|
||||
});
|
||||
});
|
||||
+41
-1
@@ -1,6 +1,6 @@
|
||||
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { checkDuplicates } from '../automationUtils';
|
||||
import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
|
||||
|
||||
describe('checkDuplicates', () => {
|
||||
it('should return undefined if there are no duplicates', () => {
|
||||
@@ -22,3 +22,43 @@ describe('checkDuplicates', () => {
|
||||
expect(checkDuplicates(triggers)).toStrictEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupTriggersByAutomation', () => {
|
||||
it('returns an empty object when there are no triggers', () => {
|
||||
expect(groupTriggersByAutomation([])).toEqual({});
|
||||
});
|
||||
|
||||
it('collects the lifecycles each automation is bound to', () => {
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' },
|
||||
];
|
||||
|
||||
expect(groupTriggersByAutomation(triggers)).toEqual({
|
||||
a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
|
||||
b: [TimerLifeCycle.onLoad],
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => {
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||
];
|
||||
|
||||
expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('operators', () => {
|
||||
it('does not offer not_contains, which the server validation rejects', () => {
|
||||
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycles', () => {
|
||||
it('uses the shared user facing labels', () => {
|
||||
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
|
||||
import { TimerLifeCycle as Cycle } from 'ontime-types';
|
||||
|
||||
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
|
||||
|
||||
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
|
||||
ontime: 'Ontime automations',
|
||||
playback: 'Playback and cue systems',
|
||||
video: 'Video and streaming',
|
||||
messaging: 'Webhooks and messaging',
|
||||
};
|
||||
|
||||
/** presentation order, empty categories are not rendered */
|
||||
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
|
||||
|
||||
export type RecipeParam = {
|
||||
name: string;
|
||||
label: string;
|
||||
/** one line under the field, for anything the label cannot say */
|
||||
hint?: string;
|
||||
type?: 'text' | 'number' | 'choice';
|
||||
/** required by 'choice', which renders a select rather than a free field */
|
||||
options?: { value: string; label: string }[];
|
||||
/** takes a whole row: addresses and free text read badly in a narrow column */
|
||||
wide?: boolean;
|
||||
/** every default points at this machine, so a recipe cannot reach a venue network unasked */
|
||||
defaultValue: string;
|
||||
};
|
||||
|
||||
export type RecipeValues = Record<string, string>;
|
||||
|
||||
export type AutomationRecipe = {
|
||||
/** stable, client only. Never persisted */
|
||||
id: string;
|
||||
title: string;
|
||||
/** one line, plain language: what this does for the user */
|
||||
description: string;
|
||||
category: RecipeCategory;
|
||||
/** extra search terms: other names for the software, its protocol, the job it does */
|
||||
keywords?: string[];
|
||||
/** what the dialog asks for. Empty when the recipe needs nothing */
|
||||
params: RecipeParam[];
|
||||
triggers: TimerLifeCycle[];
|
||||
/** typed, so the compiler catches a recipe drifting from the automation schema */
|
||||
build: (values: RecipeValues) => AutomationDTO;
|
||||
};
|
||||
|
||||
const auxTimers = [
|
||||
{ value: '1', label: 'Aux timer 1' },
|
||||
{ value: '2', label: 'Aux timer 2' },
|
||||
{ value: '3', label: 'Aux timer 3' },
|
||||
];
|
||||
|
||||
type AuxNumber = '1' | '2' | '3';
|
||||
|
||||
/**
|
||||
* Action keys are a union the compiler checks against the automation schema, so the aux
|
||||
* number is resolved through maps rather than string interpolation. Anything unexpected
|
||||
* falls back to the first timer instead of building an action the server would reject.
|
||||
*/
|
||||
function toAux(value: string): AuxNumber {
|
||||
return value === '2' || value === '3' ? value : '1';
|
||||
}
|
||||
|
||||
const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
|
||||
const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
|
||||
const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
|
||||
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
|
||||
|
||||
/** a user pasting an address is as likely to include the trailing slash as not */
|
||||
function origin(value: string): string {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** the recipe cannot know whether the user's URL already carries a query */
|
||||
function withQuery(url: string, query: string): string {
|
||||
const trimmed = url.trim();
|
||||
return trimmed.includes('?') ? `${trimmed}&${query}` : `${trimmed}?${query}`;
|
||||
}
|
||||
|
||||
export const automationRecipes: AutomationRecipe[] = [
|
||||
{
|
||||
id: 'ontime-aux-timer',
|
||||
title: 'Run an aux timer with the event',
|
||||
description: 'Sets an aux timer and starts it whenever an event starts.',
|
||||
category: 'ontime',
|
||||
keywords: ['countdown', 'stage timer', 'speaker'],
|
||||
params: [
|
||||
{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' },
|
||||
{ name: 'duration', label: 'Duration', hint: 'hh:mm:ss', defaultValue: '00:05:00' },
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ aux, duration }) => ({
|
||||
title: `Run Aux Timer ${toAux(aux)} with the event`,
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{ type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
|
||||
{ type: 'ontime', action: auxStart[toAux(aux)] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ontime-aux-stop',
|
||||
title: 'Stop the aux timer when the event ends',
|
||||
description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
|
||||
category: 'ontime',
|
||||
keywords: ['countdown', 'stage timer', 'reset'],
|
||||
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
|
||||
triggers: [Cycle.onFinish],
|
||||
build: ({ aux }) => ({
|
||||
title: `Stop Aux Timer ${toAux(aux)} on finish`,
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: auxStop[toAux(aux)] }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ontime-warn-stage',
|
||||
title: 'Warn the stage when time runs low',
|
||||
description: 'Shows a message on the stage timer as the running event enters its danger window.',
|
||||
category: 'ontime',
|
||||
keywords: ['message', 'danger', 'wrap up', 'presenter'],
|
||||
params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }],
|
||||
triggers: [Cycle.onDanger],
|
||||
build: ({ message }) => ({
|
||||
title: 'Warn the stage at danger',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: message, visible: true }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ontime-clear-message',
|
||||
title: 'Hide the stage message on finish',
|
||||
description: 'Clears the stage message once the event finishes. Pairs with the warning above.',
|
||||
category: 'ontime',
|
||||
keywords: ['message', 'clear', 'presenter'],
|
||||
params: [],
|
||||
triggers: [Cycle.onFinish],
|
||||
build: () => ({
|
||||
title: 'Hide the stage message on finish',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ontime-secondary-message',
|
||||
title: 'Show an aux timer beside the stage message',
|
||||
description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
|
||||
category: 'ontime',
|
||||
keywords: ['message', 'secondary', 'stage', 'countdown'],
|
||||
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
|
||||
triggers: [Cycle.onLoad],
|
||||
build: ({ aux }) => ({
|
||||
title: `Show Aux Timer ${toAux(aux)} as the secondary message`,
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'qlab-go',
|
||||
title: 'QLab — fire the matching cue',
|
||||
description: "Starts the QLab cue whose number matches the Ontime event's cue.",
|
||||
category: 'playback',
|
||||
keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'],
|
||||
params: [
|
||||
{
|
||||
name: 'ip',
|
||||
label: 'QLab computer',
|
||||
hint: 'IP address of the machine running QLab',
|
||||
wide: true,
|
||||
defaultValue: '127.0.0.1',
|
||||
},
|
||||
{ name: 'port', label: 'OSC port', type: 'number', hint: "QLab's default is 53000", defaultValue: '53000' },
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ ip, port }) => ({
|
||||
title: 'QLab GO on event start',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{
|
||||
type: 'osc',
|
||||
targetIP: ip.trim(),
|
||||
targetPort: Number(port),
|
||||
address: '/cue/{{eventNow.cue}}/start',
|
||||
args: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'companion-press',
|
||||
title: 'Companion — press a button',
|
||||
description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.',
|
||||
category: 'playback',
|
||||
keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'],
|
||||
params: [
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Companion address',
|
||||
hint: 'Where the Companion HTTP API is listening',
|
||||
wide: true,
|
||||
defaultValue: 'http://127.0.0.1:8888',
|
||||
},
|
||||
{ name: 'page', label: 'Page', type: 'number', defaultValue: '1' },
|
||||
{ name: 'row', label: 'Row', type: 'number', defaultValue: '0' },
|
||||
{ name: 'column', label: 'Column', type: 'number', defaultValue: '0' },
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ host, page, row, column }) => ({
|
||||
title: 'Companion button press',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
// Companion HTTP API: /api/location/<page>/<row>/<column>/press
|
||||
outputs: [{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'vmix-overlay-warning',
|
||||
title: 'vMix — show an overlay on warning',
|
||||
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
|
||||
category: 'video',
|
||||
keywords: ['streaming', 'http', 'lower third', 'graphics'],
|
||||
params: [
|
||||
{
|
||||
name: 'host',
|
||||
label: 'vMix address',
|
||||
hint: 'The vMix web controller',
|
||||
wide: true,
|
||||
defaultValue: 'http://127.0.0.1:8088',
|
||||
},
|
||||
{ name: 'overlay', label: 'Overlay number', type: 'number', defaultValue: '1' },
|
||||
],
|
||||
triggers: [Cycle.onWarning],
|
||||
build: ({ host, overlay }) => ({
|
||||
title: 'vMix overlay on warning',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'webhook-event-title',
|
||||
title: 'Webhook — post the running event',
|
||||
description: 'Calls any URL with the running event title, as a template string you can edit afterwards.',
|
||||
category: 'messaging',
|
||||
keywords: ['http', 'rest', 'api', 'integration', 'slack'],
|
||||
params: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
hint: 'The event title is added as a title parameter',
|
||||
wide: true,
|
||||
defaultValue: 'http://127.0.0.1:3000/now',
|
||||
},
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ url }) => ({
|
||||
title: 'Webhook with the current event',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: withQuery(url, 'title={{eventNow.title}}') }],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/** the values the dialog starts with, so a recipe can be created without touching a field */
|
||||
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
|
||||
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A recipe that only sends Ontime actions works the moment it is created.
|
||||
* Anything else points at software we cannot locate for the user.
|
||||
*/
|
||||
export function needsTarget(recipe: AutomationRecipe): boolean {
|
||||
return !recipe.build(defaultValues(recipe)).outputs.every((output) => output.type === 'ontime');
|
||||
}
|
||||
@@ -1,4 +1,20 @@
|
||||
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
|
||||
|
||||
/**
|
||||
* Names a trigger created from an automation's lifecycle picker.
|
||||
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
|
||||
*/
|
||||
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
|
||||
return `${automationTitle} — ${getLifecycleLabel(cycle)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs are a union, so react-hook-form cannot resolve a field's error by name.
|
||||
* Every output card knows which fields it registered, this just makes them reachable.
|
||||
*/
|
||||
export type OutputErrors = Partial<Record<string, { message?: string }>>;
|
||||
|
||||
type CycleLabel = {
|
||||
id: number;
|
||||
@@ -7,15 +23,29 @@ type CycleLabel = {
|
||||
};
|
||||
|
||||
export const cycles: CycleLabel[] = [
|
||||
{ id: 1, label: 'On Load', value: 'onLoad' },
|
||||
{ id: 2, label: 'On Start', value: 'onStart' },
|
||||
{ id: 3, label: 'On Pause', value: 'onPause' },
|
||||
{ id: 4, label: 'On Stop', value: 'onStop' },
|
||||
{ id: 5, label: 'Every second', value: 'onClock' },
|
||||
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
|
||||
{ id: 7, label: 'On Finish', value: 'onFinish' },
|
||||
{ id: 8, label: 'On Warning', value: 'onWarning' },
|
||||
{ id: 9, label: 'On Danger', value: 'onDanger' },
|
||||
{ id: 1, label: lifecycleLabels.onLoad, value: 'onLoad' },
|
||||
{ id: 2, label: lifecycleLabels.onStart, value: 'onStart' },
|
||||
{ id: 3, label: lifecycleLabels.onPause, value: 'onPause' },
|
||||
{ id: 4, label: lifecycleLabels.onStop, value: 'onStop' },
|
||||
{ id: 5, label: lifecycleLabels.onClock, value: 'onClock' },
|
||||
{ id: 6, label: lifecycleLabels.onUpdate, value: 'onUpdate' },
|
||||
{ id: 7, label: lifecycleLabels.onFinish, value: 'onFinish' },
|
||||
{ id: 8, label: lifecycleLabels.onWarning, value: 'onWarning' },
|
||||
{ id: 9, label: lifecycleLabels.onDanger, value: 'onDanger' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter operators offered in the automation form
|
||||
* NOTE: not_contains is supported by the type and by the runtime, but the server
|
||||
* validation list omits it, so an automation using it cannot be saved.
|
||||
* It stays out of the UI until the server accepts it.
|
||||
*/
|
||||
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'greater_than', label: 'is greater than' },
|
||||
{ value: 'less_than', label: 'is less than' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -83,3 +113,23 @@ export function checkDuplicates(triggers: Trigger[]) {
|
||||
}
|
||||
return duplicates.length > 0 ? duplicates : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups the lifecycles each automation is bound to
|
||||
* Used to show when an automation runs, and to highlight the ones that never will
|
||||
*/
|
||||
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
|
||||
const grouped: Record<string, TimerLifeCycle[]> = {};
|
||||
|
||||
for (const trigger of triggers) {
|
||||
if (!Object.hasOwn(grouped, trigger.automationId)) {
|
||||
grouped[trigger.automationId] = [];
|
||||
}
|
||||
// the runtime fires an automation once per lifecycle, duplicates would be noise here
|
||||
if (!grouped[trigger.automationId].includes(trigger.trigger)) {
|
||||
grouped[trigger.automationId].push(trigger.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
@@ -84,11 +84,25 @@ const staticOptions = [
|
||||
{
|
||||
id: 'automation__automations',
|
||||
label: 'Manage automations',
|
||||
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
|
||||
keywords: [
|
||||
'osc',
|
||||
'http',
|
||||
'webhook',
|
||||
'integration',
|
||||
'api',
|
||||
'output',
|
||||
'action',
|
||||
'recipe',
|
||||
'example',
|
||||
'preset',
|
||||
'qlab',
|
||||
'vmix',
|
||||
'companion',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation__triggers',
|
||||
label: 'Manage triggers',
|
||||
label: 'Global triggers',
|
||||
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
||||
},
|
||||
],
|
||||
|
||||
+8
-2
@@ -15,7 +15,7 @@
|
||||
|
||||
.triggerHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
grid-template-columns: 8rem 1fr auto 2rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
@@ -25,7 +25,7 @@
|
||||
.trigger {
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
grid-template-columns: 8rem 1fr auto 2rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
@@ -41,6 +41,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.outputTags {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.duplicateMessage {
|
||||
padding-left: 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
|
||||
@@ -6,8 +6,11 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import { eventTriggerOptions } from './eventTrigger.constants';
|
||||
|
||||
import style from './EventEditorTriggers.module.scss';
|
||||
@@ -27,7 +30,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
label: title,
|
||||
}));
|
||||
const hasAutomationOptions = allAutomationOptions.length > 0;
|
||||
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
|
||||
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: getLifecycleLabel(cycle) }));
|
||||
|
||||
const duplicateIds = new Set<string>();
|
||||
const seen = new Map<string, string>();
|
||||
@@ -76,6 +79,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
<div className={style.triggerHeader}>
|
||||
<span>Lifecycle</span>
|
||||
<span>Automation</span>
|
||||
<span>Sends</span>
|
||||
</div>
|
||||
{triggers.map((trigger) => {
|
||||
const isDuplicate = duplicateIds.has(trigger.id);
|
||||
@@ -103,6 +107,13 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
<div className={style.outputTags}>
|
||||
{summariseOutputs(automationSettings.automations[trigger.automationId]?.outputs ?? []).map(
|
||||
({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
|
||||
@@ -225,4 +225,44 @@ describe('deleteAutomation()', () => {
|
||||
const removed = getAutomations();
|
||||
expect(Object.keys(removed).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('takes the automation global triggers with it, and leaves the others alone', async () => {
|
||||
const doomed = Object.keys(getAutomations())[0];
|
||||
const survivor = await addAutomation({ title: 'survivor', filterRule: 'all', filters: [], outputs: [] });
|
||||
|
||||
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId: doomed });
|
||||
await addTrigger({ title: 'on finish', trigger: TimerLifeCycle.onFinish, automationId: doomed });
|
||||
await addTrigger({ title: 'keep me', trigger: TimerLifeCycle.onStart, automationId: survivor.id });
|
||||
|
||||
await deleteAutomation({}, doomed);
|
||||
|
||||
// a trigger pointing at nothing never fires, so it must not outlive its automation
|
||||
expect(getAutomationTriggers()).toEqual([expect.objectContaining({ title: 'keep me' })]);
|
||||
expect(Object.keys(getAutomations())).toEqual([survivor.id]);
|
||||
});
|
||||
|
||||
it('refuses an automation attached to an event, and keeps its triggers', async () => {
|
||||
const automationId = Object.keys(getAutomations())[0];
|
||||
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId });
|
||||
|
||||
const projectRundowns: ProjectRundowns = {
|
||||
'rundown-1': {
|
||||
id: 'rundown-1',
|
||||
title: 'Rundown 1',
|
||||
order: ['1'],
|
||||
flatOrder: ['1'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({
|
||||
id: '1',
|
||||
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
|
||||
}),
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
|
||||
expect(getAutomationTriggers()).toHaveLength(1);
|
||||
expect(Object.keys(getAutomations())).toEqual([automationId]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,24 +145,18 @@ export async function deleteAutomation(projectRundowns: ProjectRundowns, automat
|
||||
return;
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in triggers
|
||||
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === automationId);
|
||||
if (triggers.length) {
|
||||
const firstTrigger = triggers[0];
|
||||
const triggerTitle = firstTrigger?.title ?? 'Unknown trigger';
|
||||
throw new Error(
|
||||
`Unable to delete automation used in trigger ${triggerTitle}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in events
|
||||
// prevent deleting a automation that is in use in events, the user has to unlink it there
|
||||
const isInUse = isAutomationUsed(projectRundowns, automationId);
|
||||
if (isInUse) {
|
||||
throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`);
|
||||
}
|
||||
|
||||
// a global trigger without its automation is dead data, so it goes with it.
|
||||
// Both are written in a single patch, there is no state where one outlived the other
|
||||
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId !== automationId);
|
||||
|
||||
delete automations[automationId];
|
||||
await saveChanges({ automations });
|
||||
await saveChanges({ automations, triggers });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user