refactor(automation): give triggers back to the triggers list

The automation form had grown a lifecycle picker that created and deleted
global triggers behind the user's back. It broke the model the panel is built
on — an automation is what to send, a trigger is when — and left the global
triggers section describing itself as a place to rename things made elsewhere.

Triggers now belong only to the triggers list and the event editor. The
automation form edits a title, filters and outputs, and saves in one request:
the two-request save, its partial-failure recovery and the snapshot it
reconciled against are all gone with it. An automation with no global trigger
offers to make one from its own row, opening the trigger form with the
automation preselected, so the connection is still one click away without the
form pretending to own it.

Other changes in the same pass:

- the form uses the compact modal instead of the wide one. Nothing in it
  justified 1800px, and output fields now pair up two to a row rather than
  stretching across four columns
- a blank automation gets its own header button beside Start from recipe,
  instead of being a footnote under the recipe list
- the settings form seeds itself from the query when it resolves, as the other
  settings panels do. It was showing automations as OFF while they were on
- the filter operator list goes back to the three master offered, which makes
  the note about not_contains unnecessary rather than explanatory
- comments that narrated the change rather than explaining the code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
This commit is contained in:
Claude
2026-09-09 19:36:09 +00:00
parent 3d23d53555
commit a4e83dd9a0
11 changed files with 279 additions and 436 deletions
@@ -1,18 +1,3 @@
/**
* 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 { .outerColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -20,8 +5,6 @@
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
color: $ui-white; color: $ui-white;
padding-block: 0.5rem; padding-block: 0.5rem;
// leaves the overlay scrollbar somewhere to sit without covering a field
padding-right: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -83,9 +66,13 @@
color: $secondary-text-gray; color: $secondary-text-gray;
} }
/**
* Two columns, so fields that belong together sit on one row: a host and its port,
* a filter field and its operator. Anything wider than half a card opts out with spanFull
*/
.cardBody { .cardBody {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem 0.75rem; gap: 0.5rem 0.75rem;
padding: 0.75rem; padding: 0.75rem;
} }
@@ -1,24 +1,9 @@
import { import { Automation, AutomationDTO, AutomationFilter, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
Automation,
AutomationDTO,
AutomationFilter,
TimerLifeCycle,
Trigger,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
addAutomation,
addTrigger,
deleteTrigger,
editAutomation,
testOutput,
} from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
@@ -28,13 +13,12 @@ import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal'; import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils'; import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm'; import HttpOutputForm from './HttpOutputForm';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
import OscOutputForm from './OscOutputForm'; import OscOutputForm from './OscOutputForm';
@@ -48,55 +32,21 @@ const formId = 'automation-form';
/** how long a successful test keeps its confirmation on screen */ /** how long a successful test keeps its confirmation on screen */
const testFeedbackDuration = 2000; const testFeedbackDuration = 2000;
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
triggers: Trigger[];
onClose: () => void; onClose: () => void;
} }
export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) { /**
* Edits what an automation sends: its filters and its outputs.
* When it runs is a separate concept, owned by the triggers list and by the event editor.
*/
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
/**
* The triggers the server holds for this automation, as far as this form knows.
*
* Seeded from a snapshot taken when the form opens, never from 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. It then advances as each request succeeds, so a save that fails
* half way leaves only the outstanding work for the retry.
*/
const [syncedTriggers, setSyncedTriggers] = useState<Trigger[]>(() =>
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
);
const syncedCycles = useMemo(
() => Array.from(new Set(syncedTriggers.map((trigger) => trigger.trigger))),
[syncedTriggers],
);
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
/** 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);
// both are deduped, so equal lengths and one being a subset makes them the same selection
const cyclesAreDirty =
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.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 = syncedTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
/** /**
* Test results are keyed by the field array id rather than the index: * 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 * removing an output shifts every index after it, which would leave feedback on the wrong row
@@ -216,59 +166,21 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
} }
}; };
/**
* Reconciles the lifecycle selection against the global triggers.
* Runs after the automation itself is saved: a new automation has no id until then.
*
* Every request advances the synced snapshot as it succeeds, so pressing save again after
* a failure half way through retries only what is left. Without that a retry would re-add
* a trigger it already created, and re-delete one it already deleted, which the server
* rejects outright.
*/
const syncTriggers = async (automationId: string, title: string) => {
for (const trigger of triggersToRemove) {
await deleteTrigger(trigger.id);
setSyncedTriggers((prev) => prev.filter((synced) => synced.id !== trigger.id));
}
const toAdd = selectedCycles.filter((cycle) => !syncedCycles.includes(cycle));
for (const cycle of toAdd) {
const created = await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId });
setSyncedTriggers((prev) => [...prev, created]);
}
};
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
// a stale failure from the previous attempt would otherwise sit under a successful retry // a stale failure from the previous attempt would otherwise sit under a successful retry
clearErrors('root'); clearErrors('root');
// 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 { try {
if (existingId) { if (isAutomation(automation)) {
await editAutomation(existingId, { id: existingId, ...values }); await editAutomation(automation.id, { id: automation.id, ...values });
automationId = existingId;
} else { } else {
const created = await addAutomation(values); await addAutomation(values);
setCreatedId(created.id);
automationId = created.id;
} }
} catch (error) { } catch (error) {
setError('root', { message: maybeAxiosError(error) }); setError('root', { message: maybeAxiosError(error) });
return; 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(); refetch();
onClose(); onClose();
}; };
@@ -288,14 +200,10 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`; return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`;
}; };
/** // a failed save lands on `root`, which react-hook-form counts against isValid.
* A failed save reports itself as a root error, which react-hook-form counts against // Only errors on actual fields should stand between the user and another attempt
* isValid. Left alone that disables the very retry the message is asking the user to make,
* so a root error on its own does not block submitting again.
*/
const invalidFields = Object.keys(errors).filter((field) => field !== 'root'); const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0); const canSubmit = !isSubmitting && isDirty && (isValid || invalidFields.length === 0);
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
return ( return (
<Modal <Modal
@@ -303,13 +211,11 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
onClose={onClose} onClose={onClose}
showBackdrop showBackdrop
showCloseButton showCloseButton
size='wide' size='compact'
title={isEdit ? 'Edit automation' : 'Create automation'} title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={ bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Automation options</h3>
<div className={style.titleSection}> <div className={style.titleSection}>
<label> <label>
Title Title
@@ -321,44 +227,6 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </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>
<div className={style.innerColumn}> <div className={style.innerColumn}>
@@ -430,7 +298,7 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
/> />
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label> </label>
<label> <label className={style.spanFull}>
Value to match Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' /> <Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label> </label>
@@ -535,7 +403,6 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</ScrollArea>
</form> </form>
} }
footerElements={ footerElements={
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { editAutomationSettings } from '../../../../common/api/automation'; import { editAutomationSettings } from '../../../../common/api/automation';
@@ -50,13 +51,18 @@ export default function AutomationSettingsForm({
}, },
}); });
// the panel renders before the query resolves, so the form is seeded with placeholder
// settings. Take the loaded ones when they arrive, as the other settings panels do
useEffect(() => {
reset({ enabledAutomations, enabledOscIn, oscPortIn });
}, [enabledAutomations, enabledOscIn, oscPortIn, reset]);
const onSubmit = async (formData: AutomationSettingsProps) => { const onSubmit = async (formData: AutomationSettingsProps) => {
try { try {
await editAutomationSettings(formData); await editAutomationSettings(formData);
reset(formData); reset(formData);
// the rest of the panel reads these settings from the query, and the automations list // the rest of the panel reads these flags from the query, which is otherwise only
// greys itself out while they are off. Without this it keeps the stale answer until the // refreshed on a slow poll: refetch so a toggle takes effect where it is visible
// slow poll comes round, so turning automations on appears to do nothing
await refetch(); await refetch();
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
@@ -1,6 +1,6 @@
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoSparkles, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
@@ -15,6 +15,7 @@ import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog'; import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog'; import NewAutomationDialog from './NewAutomationDialog';
import { getLifecycleLabel } from './timerLifecycle'; import { getLifecycleLabel } from './timerLifecycle';
import TriggerForm from './TriggerForm';
import style from './AutomationsList.module.scss'; import style from './AutomationsList.module.scss';
@@ -40,46 +41,49 @@ export default function AutomationsList({
}: AutomationsListProps) { }: AutomationsListProps) {
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null); const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
const [isPickingStart, setIsPickingStart] = useState(false); const [isPickingRecipe, setIsPickingRecipe] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null); const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
/** the automation a new global trigger should point at, set from the row that asked for it */
const [triggerTarget, setTriggerTarget] = useState<Automation | null>(null);
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
const automationIds = Object.keys(automations); const automationIds = Object.keys(automations);
/** a recipe creates the automation itself, so it lands in the list rather than in a form */ /** a recipe creates the automation itself, so it lands in the list rather than in a form */
const handleCreated = async () => { const handleCreated = async () => {
setIsPickingStart(false); setIsPickingRecipe(false);
await refetch(); await refetch();
}; };
const handleStartEmpty = () => {
setIsPickingStart(false);
setEditing(emptyAutomation);
};
const handleDeleted = async () => { const handleDeleted = async () => {
setDeleteTarget(null); setDeleteTarget(null);
await refetch(); await refetch();
}; };
const handleTriggerCreated = async () => {
setTriggerTarget(null);
await refetch();
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{editing !== null && ( {editing !== null && (
<AutomationForm <AutomationForm
// the form snapshots the automation's lifecycles on mount, so it must never be // the form seeds itself from the automation once, so it must never be reused across two of them
// reused across two different automations
key={isAutomation(editing) ? editing.id : 'new'} key={isAutomation(editing) ? editing.id : 'new'}
automation={editing} automation={editing}
triggers={triggers}
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
/> />
)} )}
{isPickingStart && ( {isPickingRecipe && <NewAutomationDialog onClose={() => setIsPickingRecipe(false)} onCreated={handleCreated} />}
<NewAutomationDialog {triggerTarget !== null && (
onClose={() => setIsPickingStart(false)} <TriggerForm
onStartEmpty={handleStartEmpty} automations={automations}
onCreated={handleCreated} trigger={null}
automationId={triggerTarget.id}
onCancel={() => setTriggerTarget(null)}
postSubmit={handleTriggerCreated}
/> />
)} )}
{deleteTarget !== null && ( {deleteTarget !== null && (
@@ -92,9 +96,14 @@ export default function AutomationsList({
)} )}
<Panel.SubHeader> <Panel.SubHeader>
Manage automations Manage automations
<Button onClick={() => setIsPickingStart(true)}> <Panel.InlineElements>
<Button onClick={() => setIsPickingRecipe(true)}>
Start from recipe <IoSparkles />
</Button>
<Button onClick={() => setEditing(emptyAutomation)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
@@ -121,11 +130,16 @@ export default function AutomationsList({
{!isLoading && automationIds.length === 0 && ( {!isLoading && automationIds.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
title='No automations yet' title='No automations yet'
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.' description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. A recipe fills one in for a known workflow, like a video switcher or a chat channel.'
action={ action={
<Button variant='primary' onClick={() => setIsPickingStart(true)}> <Panel.InlineElements>
<Button variant='primary' onClick={() => setIsPickingRecipe(true)}>
Start from recipe <IoSparkles />
</Button>
<Button onClick={() => setEditing(emptyAutomation)}>
New automation <IoAdd /> New automation <IoAdd />
</Button> </Button>
</Panel.InlineElements>
} }
/> />
)} )}
@@ -139,12 +153,14 @@ export default function AutomationsList({
<td>{automation.title}</td> <td>{automation.title}</td>
<td> <td>
{/* {/*
* Only global triggers are visible here: an automation can also be attached to * Only global triggers are listed here: an automation can also be attached to
* single events, which live in the rundown. An empty cell is therefore not the * single events, which live in the rundown. No global trigger therefore does not
* same as never running, so it says nothing rather than claiming that. * mean it never runs, so the cell offers to add one rather than claiming anything.
*/} */}
{lifecycles.length === 0 ? ( {lifecycles.length === 0 ? (
<span className={style.muted}></span> <Button size='small' variant='subtle' onClick={() => setTriggerTarget(automation)}>
Add trigger <IoAdd />
</Button>
) : ( ) : (
<div className={style.tags}> <div className={style.tags}>
{lifecycles.map((cycle) => ( {lifecycles.map((cycle) => (
@@ -31,23 +31,21 @@ import style from './NewAutomationDialog.module.scss';
interface NewAutomationDialogProps { interface NewAutomationDialogProps {
onClose: () => void; onClose: () => void;
/** hands over to the full automation form for someone who wants to start empty */
onStartEmpty: () => void;
onCreated: (automation: Automation) => void; onCreated: (automation: Automation) => void;
} }
/** /**
* The single entry point for making an automation. * Builds a working automation for a known workflow.
* *
* Two steps in one dialog rather than two stacked ones: pick a recipe, then answer only * 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 * 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. * else the recipe already decided, which is the point of having recipes at all.
*/ */
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) { export default function NewAutomationDialog({ onClose, onCreated }: NewAutomationDialogProps) {
const [selected, setSelected] = useState<AutomationRecipe | null>(null); const [selected, setSelected] = useState<AutomationRecipe | null>(null);
return selected === null ? ( return selected === null ? (
<RecipePicker onClose={onClose} onStartEmpty={onStartEmpty} onSelect={setSelected} /> <RecipePicker onClose={onClose} onSelect={setSelected} />
) : ( ) : (
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} /> <RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
); );
@@ -66,11 +64,10 @@ function matches(recipe: AutomationRecipe, query: string): boolean {
interface RecipePickerProps { interface RecipePickerProps {
onClose: () => void; onClose: () => void;
onStartEmpty: () => void;
onSelect: (recipe: AutomationRecipe) => void; onSelect: (recipe: AutomationRecipe) => void;
} }
function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) { function RecipePicker({ onClose, onSelect }: RecipePickerProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const available = useMemo( const available = useMemo(
@@ -108,7 +105,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
showBackdrop showBackdrop
showCloseButton showCloseButton
size='compact' size='compact'
title='New automation' title='Start from a recipe'
bodyElements={ bodyElements={
<div className={style.picker}> <div className={style.picker}>
<div className={style.search}> <div className={style.search}>
@@ -139,7 +136,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
{results.length === 0 && ( {results.length === 0 && (
<Panel.EmptyState <Panel.EmptyState
title='No recipe matches that' title='No recipe matches that'
description='Try the name of the software, or start from an empty automation.' description='Try the name of the software, or close this and build the automation yourself.'
/> />
)} )}
@@ -173,14 +170,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
</ScrollArea> </ScrollArea>
</div> </div>
} }
footerElements={ footerElements={<Button onClick={onClose}>Cancel</Button>}
<>
<Button variant='ghosted-white' className={style.apart} onClick={onStartEmpty}>
Start from an empty automation
</Button>
<Button onClick={onClose}>Cancel</Button>
</>
}
/> />
); );
} }
@@ -203,12 +193,9 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value })); const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
/** /**
* What this dialog has already put on the server. * What this dialog has already put on the server: creating takes one request per trigger
* * on top of the automation itself, so a second attempt edits what exists and adds only
* Creating takes one request per trigger on top of the automation itself, so a failure * the triggers still missing, rather than making a duplicate that fires the same cycles twice.
* part way through leaves work already done. Recording it means pressing create again
* edits that automation and adds only the triggers still missing, rather than making a
* second automation and firing the same cycles twice.
*/ */
const created = useRef<Automation | null>(null); const created = useRef<Automation | null>(null);
const createdCycles = useRef<Set<TimerLifeCycle>>(new Set()); const createdCycles = useRef<Set<TimerLifeCycle>>(new Set());
@@ -236,8 +223,8 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
} }
onCreated(created.current); onCreated(created.current);
} catch (error) { } catch (error) {
// what did land is a normal automation, visible in the list. Say what happened and let // whatever landed is a normal automation, visible in the list. Report the failure and
// the user press create again rather than undoing work behind their back // leave the retry to the user rather than rolling back work behind their back
setError(maybeAxiosError(error)); setError(maybeAxiosError(error));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
@@ -16,11 +16,13 @@ const formId = 'trigger-form';
interface TriggerFormProps { interface TriggerFormProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
trigger: Trigger | null; trigger: Trigger | null;
/** preselects the automation for a new trigger, used when creating one from an automation row */
automationId?: string;
onCancel: () => void; onCancel: () => void;
postSubmit: () => void; postSubmit: () => void;
} }
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) { export default function TriggerForm({ automations, trigger, automationId, onCancel, postSubmit }: TriggerFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -33,7 +35,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
defaultValues: { defaultValues: {
title: trigger?.title, title: trigger?.title,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined), trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id, automationId: trigger?.automationId ?? automationId ?? automations?.[Object.keys(automations)[0]]?.id,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -78,8 +78,8 @@ export default function TriggersList({ triggers, automations, isLoading }: Trigg
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Description> <Panel.Description>
Triggers are managed from the automation itself. This list is for naming them, or for pointing several A global trigger runs an automation at a point in the timer lifecycle, whichever event is loaded. To run an
differently named triggers at the same automation. automation on one event only, add the trigger from the event editor instead.
</Panel.Description> </Panel.Description>
{duplicates && ( {duplicates && (
<Panel.Error> <Panel.Error>
@@ -109,8 +109,8 @@ export default function TriggersList({ triggers, automations, isLoading }: Trigg
title='No triggers yet' title='No triggers yet'
description={ description={
canAdd canAdd
? '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.' ? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then pick the lifecycles it should run on.' : 'A trigger needs an automation to run. Create the automation first, then come back and decide when it should run.'
} }
action={ action={
canAdd ? ( canAdd ? (
@@ -1,6 +1,6 @@
import { TimerLifeCycle, Trigger } from 'ontime-types'; import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils'; import { checkDuplicates, cycles, groupTriggersByAutomation } from '../automationUtils';
describe('checkDuplicates', () => { describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => { it('should return undefined if there are no duplicates', () => {
@@ -51,12 +51,6 @@ describe('groupTriggersByAutomation', () => {
}); });
}); });
describe('operators', () => {
it('does not offer not_contains, which the server validation rejects', () => {
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
});
});
describe('cycles', () => { describe('cycles', () => {
it('uses the shared user facing labels', () => { it('uses the shared user facing labels', () => {
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start'); expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
@@ -2,10 +2,7 @@ import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCyc
import { getLifecycleLabel, lifecycleLabels } from './timerLifecycle'; import { getLifecycleLabel, lifecycleLabels } from './timerLifecycle';
/** /** Names the trigger a recipe creates alongside its automation, so the pair is recognisable in the triggers list */
* 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 { export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
return `${automationTitle}${getLifecycleLabel(cycle)}`; return `${automationTitle}${getLifecycleLabel(cycle)}`;
} }
@@ -34,18 +31,11 @@ export const cycles: CycleLabel[] = [
{ id: 9, label: lifecycleLabels.onDanger, value: 'onDanger' }, { id: 9, label: lifecycleLabels.onDanger, value: 'onDanger' },
]; ];
/** /** Filter operators offered in the automation form, phrased to read as a sentence in the filter summary */
* 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 }> = [ export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
{ value: 'equals', label: 'equals' }, { value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'does not equal' }, { value: 'not_equals', label: 'does not equal' },
{ value: 'contains', label: 'contains' }, { value: 'contains', label: 'contains' },
{ value: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
]; ];
/** /**
@@ -114,10 +104,7 @@ export function checkDuplicates(triggers: Trigger[]) {
return duplicates.length > 0 ? duplicates : undefined; return duplicates.length > 0 ? duplicates : undefined;
} }
/** /** Collects the lifecycles each automation is bound to, so the list can show when it runs */
* 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[]> { export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
const grouped: Record<string, TimerLifeCycle[]> = {}; const grouped: Record<string, TimerLifeCycle[]> = {};
@@ -118,8 +118,6 @@ describe('deleteTrigger()', () => {
}); });
it('ignores a trigger that is already gone', async () => { it('ignores a trigger that is already gone', async () => {
// a client reconciling several triggers must not be stuck because another client
// removed one of them first: the end state it asked for is the one it gets
const before = getAutomationTriggers(); const before = getAutomationTriggers();
await expect(deleteTrigger('never-existed')).resolves.toBeUndefined(); await expect(deleteTrigger('never-existed')).resolves.toBeUndefined();
expect(getAutomationTriggers()).toEqual(before); expect(getAutomationTriggers()).toEqual(before);
@@ -87,9 +87,8 @@ export async function deleteTrigger(id: string): Promise<void> {
const triggers = getAutomationTriggers(); const triggers = getAutomationTriggers();
const index = triggers.findIndex((trigger) => trigger.id === id); const index = triggers.findIndex((trigger) => trigger.id === id);
// ignore request if the trigger does not exist, as deleteAutomation does for the same reason: // deleting is idempotent, as it is in deleteAutomation: the state the caller asked for
// the caller asked for it to be gone and it is, and failing here makes a client that is // already holds, and erroring would only punish a client that raced another one
// reconciling several triggers unable to finish once another client removed one of them
if (index === -1) { if (index === -1) {
return; return;
} }