Compare commits

..

7 Commits

Author SHA1 Message Date
Claude c361791080 fix(automation): stop the list claiming an automation never runs
An automation can be triggered two ways, and they are stored in different
places: global triggers live in the automation settings, event triggers live
on the event itself, inside the rundown. The panel only ever receives the
first list.

So the Runs on column was reading an automation attached to ten events as
having no triggers at all, and saying "Never runs" about something that runs
perfectly well. Worse than a missing hint: it taught that event triggers do
not count.

The client cannot honestly answer the question either, since it holds only
the rundown that is currently loaded and a project can have several. So the
column no longer answers it. An empty cell now reads as an em dash, the same
as the filter rule column beside it, and claims nothing.

The "No outputs" tag stays. An automation with nothing to send does nothing
whatever triggers it, and that the panel can see for itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 19:58:39 +00:00
Claude 126abcbd93 fix(automation): let the automation form scroll, and grow the Ontime recipes
The automation form opens in the wide modal, whose body sets overflow hidden
because a wide modal is expected to manage its own scrolling. This one did
not, so the form was simply clipped: with five outputs the content ran to
2004px inside a 590px box and both the last outputs and Save were out of
reach, with nothing on screen to say so.

The form now owns its scrolling through the shared ScrollArea, and so does
the recipe list, which caps rather than fixes its height so the dialog still
shrinks to two rows when a search narrows it. The search moves out of the
scrolling region, which retires the sticky positioning that stood in for it.

The recipe section for Ontime's own actions was named after a property,
'Works out of the box', while every other section is named after what it
talks to. It is now 'Ontime automations' and carries the actions that were
missing: stopping an aux timer on finish, and pointing the stage timer's
secondary field at one.

Those need to know which of the three aux timers you mean, which is a choice
rather than something to type, so a parameter can now declare options and
render as a select. Action keys are a union the compiler checks against the
schema, so the aux number resolves through maps rather than string
interpolation: an unexpected value falls back to the first timer instead of
building an action the server would reject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 19:12:32 +00:00
Claude 2ddd496c78 feat(automation): ask a recipe for its parameters instead of its whole form
A recipe knows almost everything about the automation it makes. What it cannot
know is where your gear is: which machine runs QLab, which Companion button,
how long the aux timer should run. Handing the user the full automation form
to supply three of those made them read a form of twenty fields to change one.

A recipe now declares its parameters, and picking one asks for those and
nothing else. Create makes the automation and its trigger through the same
endpoints the form uses, so what lands in the list is an ordinary automation
with nothing special about it.

Each recipe carries a `build` function rather than a literal, which is what
lets the answers reach the outputs — a Companion address and a page, row and
column become one URL. That also absorbs the two things a user does without
thinking: an address pasted with a trailing slash, and a webhook URL that
already carries a query string. Every default still points at loopback.

For the list to hold many recipes it has to be searchable and grouped, so it
is both. Search matches the title, the description, the category and a
keywords list, so the Companion recipe answers to "stream deck" and "elgato"
and QLab answers to "osc" and "audio". Enter takes the top result. Escape
clears the search rather than closing the dialog, which is the behaviour the
settings search already has.

The picker and the parameter step are two views of one dialog rather than two
stacked modals, so Back means back rather than dismissing everything. Rows
carry only the lifecycle tag: with many recipes, the description and one tag
is what stays readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 19:04:57 +00:00
Claude 25eb68452c refactor(automation): split the output kinds out of the form
AutomationForm rendered all three output kinds inline, so the file mixed the
form's own concerns with the fields of every protocol it can speak. Each kind
now has a component next to OntimeActionForm, which was already there and was
the odd one out, and OutputCard owns the chrome they share. The form drops
from ~700 lines to ~540 and the map over the outputs reads as three cases.

Three copies of an inline structural cast existed only to reach an output
field's error by name, which react-hook-form cannot resolve through a union.
One OutputErrors type replaces all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 15:28:25 +00:00
Claude a905bf912f feat(automation): one step creation, recipes, and a panel that says what it does
Making an automation took two visits to two lists. You wrote the automation
in one, then remembered that an automation alone never runs, went to the
trigger list, made a trigger, and pointed it back at what you had just made.
A first time user who stopped after the first step got a thing that looked
finished and did nothing.

Lifecycles are now picked on the automation form, as chips. Saving reconciles
the triggers behind it, diffed against a snapshot taken when the form opened
so a save never removes a trigger the user could not see. Every lifecycle is
in one place, including the two that fire continuously, which say so before
you pick them rather than after your log fills up.

That leaves the trigger list as what it actually is: a place to rename a
generated trigger, or to point several differently named ones at the same
automation. It says so, and it names the triggers whose automation is gone.

New opens one list of starting points: an empty automation, then a handful of
recipes for the software people actually pair Ontime with. A recipe is
nothing but a pre-filled form, so choosing one opens the ordinary automation
form with its values in place. The user reads what it will do, points it at
their own gear and saves. Nothing is written until they do, and there is no
second creation path to keep working: one request, the same as any other
automation. Every recipe targets loopback, so one saved without thinking
cannot put traffic on a venue network.

The list now answers what an automation does without opening it: when it
runs, whether it filters, and what it sends. An automation with no trigger or
no output is the common half-finished state and is called out rather than
shown as a blank cell. Outputs are cards with their type, a summary and a
Test button in the header, which is also where the test says whether it
worked: the panel used to send and tell the user nothing either way.

Deleting confirms first and names the triggers that go with it, instead of
dumping the server's refusal into a stray row under the table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 15:28:10 +00:00
Claude 149cc0da92 fix(automation): delete an automation together with its global triggers
The server refused to delete an automation while any global trigger pointed
at it, so removing one meant finding its triggers in a second list and
deleting each by hand first.

The refusal was protecting against dead data, but a trigger pointing at a
deleted automation is the dead data. Both are now written in a single patch,
so there is no window where one outlives the other and nothing to roll back.

An automation attached to an event is still refused: that reference lives in
the rundown, and removing it is an edit to the show rather than to the
automation settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 15:27:27 +00:00
Claude 419ca5c4ca refactor(automation): name lifecycles and outputs the same way everywhere
A lifecycle was written three ways depending on where you were standing:
"On Start" in the automation settings, the raw "onStart" in the event editor,
and again as a literal in the trigger list. Anyone attaching an automation to
an event had to work out that the two lists were the same list.

One label map now owns the user facing names, and one helper summarises what
an automation sends. Both are shared, so the event editor gains the labels and
a Sends column for free: the trigger row says which automation runs and what
it will do, instead of only its title.

Two things the panel could not say before, now that it can look them up:

- A trigger can outlive the automation it points at, say after a partial
  project import. It rendered as an empty tag; it now says so.
- not_contains is in the type and the runtime, but the server validation list
  omits it, so an automation using it cannot be saved. The operator list moves
  out of the form and drops it, with a test to stop it coming back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
2026-09-06 15:27:06 +00:00
27 changed files with 2087 additions and 519 deletions
@@ -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 }));
}
@@ -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;
}
@@ -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>&nbsp;</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>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
{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>&nbsp;</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>
</>
);
}
@@ -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>
@@ -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;
}
@@ -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>
@@ -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>
);
}
@@ -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;
}
@@ -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}>
@@ -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');
});
});
@@ -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'],
},
],
@@ -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 });
}
/**