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
This commit is contained in:
Claude
2026-09-06 19:04:33 +00:00
parent 25eb68452c
commit 2ddd496c78
7 changed files with 654 additions and 176 deletions
@@ -33,7 +33,7 @@ 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, operators, type OutputErrors } from './automationUtils'; import { cycles, isAutomation, makeFieldList, makeTriggerTitle, 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';
@@ -54,12 +54,10 @@ interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
/** global triggers, used to resolve which lifecycles this automation is currently bound to */ /** global triggers, used to resolve which lifecycles this automation is currently bound to */
triggers: Trigger[]; triggers: Trigger[];
/** lifecycles a new automation starts with selected, used by recipes */
defaultCycles?: TimerLifeCycle[];
onClose: () => void; onClose: () => void;
} }
export default function AutomationForm({ automation, triggers, defaultCycles, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
@@ -79,10 +77,7 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on
() => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))), () => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))),
[initialTriggers], [initialTriggers],
); );
// a new automation can arrive pre-filled from a recipe, an existing one resolves its own triggers const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles);
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(
isEdit ? initialCycles : (defaultCycles ?? []),
);
/** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */ /** 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 [createdId, setCreatedId] = useState<string | null>(null);
@@ -233,8 +228,7 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on
const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle)); const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle));
for (const cycle of toAdd) { for (const cycle of toAdd) {
const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle; await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId });
await addTrigger({ title: `${title}${label}`, trigger: cycle, automationId });
} }
}; };
@@ -285,8 +279,7 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on
return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`; return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`;
}; };
// a recipe arrives complete, so a new automation is savable without the user changing anything const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && isValid;
const canSubmit = !isSubmitting && (!isEdit || isDirty || cyclesAreDirty) && isValid;
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle)); const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
return ( return (
@@ -1,4 +1,4 @@
import { Automation, AutomationDTO, NormalisedAutomation, TimerLifeCycle, 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, IoTrash } from 'react-icons/io5';
@@ -13,7 +13,6 @@ import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import type { AutomationRecipe } from './automationRecipes';
import { groupTriggersByAutomation, isAutomation } from './automationUtils'; import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog'; import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog'; import NewAutomationDialog from './NewAutomationDialog';
@@ -27,13 +26,6 @@ const emptyAutomation: AutomationDTO = {
outputs: [], outputs: [],
}; };
/** what the automation form opens with: an existing automation, or a blank/pre-filled draft */
type FormState = {
automation: Automation | AutomationDTO;
/** only used when creating, an existing automation resolves its own lifecycles */
defaultCycles?: TimerLifeCycle[];
};
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
triggers: Trigger[]; triggers: Trigger[];
@@ -49,17 +41,22 @@ export default function AutomationsList({
}: AutomationsListProps) { }: AutomationsListProps) {
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const { setLocation } = useAppSettingsNavigation(); const { setLocation } = useAppSettingsNavigation();
const [formState, setFormState] = useState<FormState | null>(null); const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
const [isPickingStart, setIsPickingStart] = useState(false); const [isPickingStart, setIsPickingStart] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null); const [deleteTarget, setDeleteTarget] = 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 is only ever a pre-filled form, nothing is written until the user saves */ /** a recipe creates the automation itself, so it lands in the list rather than in a form */
const handleStartFrom = (recipe: AutomationRecipe | null) => { const handleCreated = async () => {
setIsPickingStart(false); setIsPickingStart(false);
setFormState({ automation: recipe?.automation ?? emptyAutomation, defaultCycles: recipe?.triggers }); await refetch();
};
const handleStartEmpty = () => {
setIsPickingStart(false);
setEditing(emptyAutomation);
}; };
const handleDeleted = async () => { const handleDeleted = async () => {
@@ -70,18 +67,23 @@ export default function AutomationsList({
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{formState !== null && ( {editing !== null && (
<AutomationForm <AutomationForm
// the form snapshots the automation's lifecycles on mount, so it must never be // the form snapshots the automation's lifecycles on mount, so it must never be
// reused across two different automations // reused across two different automations
key={isAutomation(formState.automation) ? formState.automation.id : 'new'} key={isAutomation(editing) ? editing.id : 'new'}
automation={formState.automation} automation={editing}
triggers={triggers} triggers={triggers}
defaultCycles={formState.defaultCycles} onClose={() => setEditing(null)}
onClose={() => setFormState(null)} />
)}
{isPickingStart && (
<NewAutomationDialog
onClose={() => setIsPickingStart(false)}
onStartEmpty={handleStartEmpty}
onCreated={handleCreated}
/> />
)} )}
{isPickingStart && <NewAutomationDialog onClose={() => setIsPickingStart(false)} onSelect={handleStartFrom} />}
{deleteTarget !== null && ( {deleteTarget !== null && (
<DeleteAutomationDialog <DeleteAutomationDialog
automation={deleteTarget} automation={deleteTarget}
@@ -174,7 +176,7 @@ export default function AutomationsList({
<IconButton <IconButton
variant='ghosted-white' variant='ghosted-white'
aria-label='Edit entry' aria-label='Edit entry'
onClick={() => setFormState({ automation })} onClick={() => setEditing(automation)}
> >
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
@@ -1,58 +1,180 @@
.list { /* ---------- step one: pick a recipe ---------- */
.picker {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
color: $ui-white; color: $ui-white;
} }
.listLabel { /** stays in view while the list scrolls under it, which is the point of having a search */
margin-top: 0.5rem; .search {
font-size: $aux-text-size; position: sticky;
color: $secondary-text-gray; top: -0.5rem;
z-index: 1;
display: flex;
align-items: center;
padding-block: 0.5rem;
margin-top: -0.5rem;
background-color: $gray-1250;
} }
.option { .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;
}
.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; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.625rem 0.75rem;
text-align: left; text-align: left;
color: inherit; color: inherit;
background-color: $black-10; background-color: transparent;
border: 1px solid $white-10; border: 1px solid transparent;
border-radius: $component-border-radius-md; border-radius: $component-border-radius-md;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: $white-3; background-color: $white-3;
border-color: $white-20; border-color: $white-10;
} }
&:focus-visible { &:focus-visible {
outline: 1px solid $action-blue; outline: 1px solid $action-blue;
outline-offset: 1px; outline-offset: -1px;
} }
} }
.optionText { .recipeText {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.125rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.optionTitle { .recipeTitle {
font-weight: 600; font-weight: 600;
} }
.optionDescription { .recipeDescription {
font-size: $aux-text-size; font-size: $aux-text-size;
color: $secondary-text-gray; color: $secondary-text-gray;
} }
.chevron { .recipeTags {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0; 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; 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;
}
@@ -1,31 +1,103 @@
import { IoChevronForward } from 'react-icons/io5'; 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 Modal from '../../../../common/components/modal/Modal';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { summariseOutputs } from '../../../../common/utils/automationOutputs'; import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { automationRecipes, needsTarget, type AutomationRecipe } from './automationRecipes'; import {
automationRecipes,
defaultValues,
needsTarget,
recipeCategoryLabels,
recipeCategoryOrder,
type AutomationRecipe,
type RecipeValues,
} from './automationRecipes';
import { makeTriggerTitle } from './automationUtils';
import style from './NewAutomationDialog.module.scss'; import style from './NewAutomationDialog.module.scss';
interface NewAutomationDialogProps { interface NewAutomationDialogProps {
onClose: () => void; onClose: () => void;
/** called with the recipe to pre-fill the form with, or null to start from an empty one */ /** hands over to the full automation form for someone who wants to start empty */
onSelect: (recipe: AutomationRecipe | null) => void; onStartEmpty: () => void;
onCreated: (automation: Automation) => void;
} }
/** /**
* The single entry point for making an automation: a list of starting points. * The single entry point for making an automation.
* Picking one opens the ordinary automation form pre-filled, so a recipe is a head start *
* rather than a separate kind of object. Nothing is saved until the user saves the form. * 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, onSelect }: NewAutomationDialogProps) { export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
// OSC is not available in the cloud service, offering those recipes there would be a lie const [selected, setSelected] = useState<AutomationRecipe | null>(null);
const recipes = isOntimeCloud
? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc')) return selected === null ? (
: automationRecipes; <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 ( return (
<Modal <Modal
@@ -33,47 +105,186 @@ export default function NewAutomationDialog({ onClose, onSelect }: NewAutomation
onClose={onClose} onClose={onClose}
showBackdrop showBackdrop
showCloseButton showCloseButton
size='compact'
title='New automation' title='New automation'
bodyElements={ bodyElements={
<div className={style.list}> <div className={style.picker}>
<button type='button' className={style.option} onClick={() => onSelect(null)}> <div className={style.search}>
<div className={style.optionText}> <IoSearch className={style.searchIcon} />
<div className={style.optionTitle}>Empty automation</div> <Input
<div className={style.optionDescription}>Start from scratch.</div> value={query}
</div> onChange={(event) => setQuery(event.target.value)}
<IoChevronForward className={style.chevron} /> onKeyDown={handleSearchKey}
</button> placeholder='Search recipes, eg. QLab, OSC, message'
className={style.searchInput}
<div className={style.listLabel}> aria-label='Search recipes'
Or start from a recipe. Each one opens as a normal automation you can edit before saving. fluid
autoFocus
/>
{trimmed.length > 0 && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.searchClear}
onClick={() => setQuery('')}
>
<IoClose />
</IconButton>
)}
</div> </div>
{recipes.map((recipe) => ( {results.length === 0 && (
<button <Panel.EmptyState
type='button' title='No recipe matches that'
key={recipe.id} description='Try the name of the software, or start from an empty automation.'
className={style.option} />
onClick={() => onSelect(recipe)} )}
aria-label={`Start from ${recipe.automation.title}`}
> {recipeCategoryOrder.map((category) => {
<div className={style.optionText}> const inCategory = results.filter((recipe) => recipe.category === category);
<div className={style.optionTitle}>{recipe.automation.title}</div> if (inCategory.length === 0) {
<div className={style.optionDescription}>{recipe.description}</div> return null;
<Panel.InlineElements relation='inner' wrap='wrap'> }
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag> return (
))} <section key={category} className={style.group}>
{summariseOutputs(recipe.automation.outputs).map(({ type, label, count }) => ( <h4 className={style.groupTitle}>{recipeCategoryLabels[category]}</h4>
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag> {inCategory.map((recipe) => (
))} <button type='button' key={recipe.id} className={style.recipe} onClick={() => onSelect(recipe)}>
{needsTarget(recipe) && <Tag variant='warning'>Point it at your device</Tag>} <div className={style.recipeText}>
</Panel.InlineElements> <div className={style.recipeTitle}>{recipe.title}</div>
</div> <div className={style.recipeDescription}>{recipe.description}</div>
<IoChevronForward className={style.chevron} /> </div>
</button> <div className={style.recipeTags}>
))} {recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
<IoChevronForward className={style.chevron} />
</div>
</button>
))}
</section>
);
})}
</div> </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 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(({ name, label, hint, type, wide }) => (
<label key={name} className={cx([style.field, wide && style.wide])}>
{label}
<Input
type={type === 'number' ? 'number' : 'text'}
value={values[name]}
onChange={(event) => setValues((prev) => ({ ...prev, [name]: event.target.value }))}
fluid
/>
{hint && <span className={style.hint}>{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,21 +1,29 @@
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types'; import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
import { automationRecipes, needsTarget } from '../automationRecipes'; import { automationRecipes, defaultValues, needsTarget, recipeCategoryOrder } from '../automationRecipes';
import { operators } from '../automationUtils'; import { operators } from '../automationUtils';
/** /**
* Recipes are shipped as constants but saved through the same endpoint as a hand written * 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 * automation. These assertions stand in for the server side validation, so a recipe cannot
* silently rot into something that 400s when the user presses save. * silently rot into something that 400s when the user presses create.
*/ */
describe('automationRecipes', () => { describe('automationRecipes', () => {
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
it('has unique ids', () => { it('has unique ids', () => {
const ids = automationRecipes.map(({ id }) => id); const ids = automationRecipes.map(({ id }) => id);
expect(new Set(ids).size).toBe(ids.length); 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', () => { it('binds every recipe to at least one valid lifecycle', () => {
for (const recipe of automationRecipes) { for (const { recipe } of built) {
expect(recipe.triggers.length).toBeGreaterThan(0); expect(recipe.triggers.length).toBeGreaterThan(0);
for (const cycle of recipe.triggers) { for (const cycle of recipe.triggers) {
expect(timerLifecycleValues).toContain(cycle); expect(timerLifecycleValues).toContain(cycle);
@@ -23,43 +31,71 @@ describe('automationRecipes', () => {
} }
}); });
it('gives every recipe a title and something to send', () => { it('builds a titled automation with something to send, from its own defaults', () => {
for (const recipe of automationRecipes) { for (const { automation } of built) {
expect(recipe.automation.title).not.toBe(''); expect(automation.title).not.toBe('');
expect(recipe.automation.outputs.length).toBeGreaterThan(0); expect(automation.outputs.length).toBeGreaterThan(0);
for (const output of recipe.automation.outputs) { for (const output of automation.outputs) {
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true); expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
} }
} }
}); });
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) {
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', () => { it('only uses filter operators the server accepts', () => {
const allowed = operators.map(({ value }) => value); const allowed = operators.map(({ value }) => value);
for (const recipe of automationRecipes) { for (const { automation } of built) {
for (const filter of recipe.automation.filters) { for (const filter of automation.filters) {
expect(allowed).toContain(filter.operator); expect(allowed).toContain(filter.operator);
} }
} }
}); });
it('defaults every external target to this machine', () => { it('defaults every external target to this machine', () => {
for (const recipe of automationRecipes) { const outputs = built.flatMap(({ automation }) => automation.outputs);
for (const output of recipe.automation.outputs) { const osc = outputs.filter(isOSCOutput);
if (isOSCOutput(output)) { const http = outputs.filter(isHTTPOutput);
expect(output.targetIP).toBe('127.0.0.1');
} // filtering rather than asserting in a branch, so a failure names the offending recipe
if (isHTTPOutput(output)) { expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]);
expect(output.url.startsWith('http://127.0.0.1')).toBe(true); 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', () => { it('flags the recipes that reach outside Ontime', () => {
for (const recipe of automationRecipes) { for (const { recipe, automation } of built) {
const reachesOut = recipe.automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output)); const reachesOut = automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
expect(needsTarget(recipe)).toBe(reachesOut); 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,119 +1,225 @@
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types'; import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
import { isOntimeAction, TimerLifeCycle as Cycle } from 'ontime-types'; import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
ontime: 'Works out of the box',
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';
/** 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>;
/**
* A recipe is a pre-filled automation form, nothing more.
* Choosing one opens the normal form with its values in place: the user reviews it,
* points it at their own gear and saves. Nothing is written until they do.
*/
export type AutomationRecipe = { export type AutomationRecipe = {
/** stable, client only. Never persisted */ /** stable, client only. Never persisted */
id: string; id: string;
title: string;
/** one line, plain language: what this does for the user */ /** one line, plain language: what this does for the user */
description: string; description: string;
/** typed so the compiler catches drift against the automation schema */ category: RecipeCategory;
automation: AutomationDTO; /** extra search terms: other names for the software, its protocol, the job it does */
/** lifecycles the form starts with selected */ keywords?: string[];
/** what the dialog asks for. Empty when the recipe needs nothing */
params: RecipeParam[];
triggers: TimerLifeCycle[]; triggers: TimerLifeCycle[];
/** typed, so the compiler catches a recipe drifting from the automation schema */
build: (values: RecipeValues) => AutomationDTO;
}; };
/** /** a user pasting an address is as likely to include the trailing slash as not */
* A recipe that only sends Ontime actions works the moment it is saved. function origin(value: string): string {
* Anything else points at software we cannot locate for the user. return value.trim().replace(/\/+$/, '');
*/ }
export function needsTarget(recipe: AutomationRecipe): boolean {
return !recipe.automation.outputs.every(isOntimeAction); /** 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}`;
} }
/**
* Every recipe targets loopback by default.
* A recipe saved without thinking must not put traffic on a venue network, so the user
* has to point it somewhere real before it can reach anything.
*
* Ordered so the ones that work out of the box come first.
*/
export const automationRecipes: AutomationRecipe[] = [ export const automationRecipes: AutomationRecipe[] = [
{ {
id: 'ontime-aux-timer', id: 'ontime-aux-timer',
description: 'Sets aux timer 1 to five minutes and starts it whenever an event starts.', title: 'Run an aux timer with the event',
automation: { description: 'Sets aux timer 1 and starts it whenever an event starts.',
title: 'Start Aux Timer 1 with the event', category: 'ontime',
keywords: ['countdown', 'stage timer', 'speaker'],
params: [{ name: 'duration', label: 'Duration', hint: 'hh:mm:ss', defaultValue: '00:05:00' }],
triggers: [Cycle.onStart],
build: ({ duration }) => ({
title: 'Run Aux Timer 1 with the event',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [ outputs: [
{ type: 'ontime', action: 'aux1-set', time: '00:05:00' }, { type: 'ontime', action: 'aux1-set', time: duration.trim() },
{ type: 'ontime', action: 'aux1-start' }, { type: 'ontime', action: 'aux1-start' },
], ],
}, }),
triggers: [Cycle.onStart],
}, },
{ {
id: 'ontime-warn-stage', id: 'ontime-warn-stage',
description: 'Shows a message on the stage timer as soon as the running event enters its danger window.', title: 'Warn the stage when time runs low',
automation: { 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', title: 'Warn the stage at danger',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [{ type: 'ontime', action: 'message-set', text: 'Please wrap up', visible: true }], outputs: [{ type: 'ontime', action: 'message-set', text: message, visible: true }],
}, }),
triggers: [Cycle.onDanger],
}, },
{ {
id: 'ontime-clear-message', id: 'ontime-clear-message',
description: 'Hides the stage message once the event finishes. Pairs with the danger warning above.', title: 'Hide the stage message on finish',
automation: { 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', title: 'Hide the stage message on finish',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }], outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
}, }),
triggers: [Cycle.onFinish],
}, },
{ {
id: 'qlab-go', id: 'qlab-go',
description: "Sends OSC to QLab to start the cue whose number matches the Ontime event's cue.", title: 'QLab — fire the matching cue',
automation: { 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', title: 'QLab GO on event start',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [ outputs: [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 53000, address: '/cue/{{eventNow.cue}}/start', args: '' }, {
type: 'osc',
targetIP: ip.trim(),
targetPort: Number(port),
address: '/cue/{{eventNow.cue}}/start',
args: '',
},
], ],
}, }),
triggers: [Cycle.onStart],
}, },
{ {
id: 'companion-press', id: 'companion-press',
description: 'Presses page 1, button 1 on a Stream Deck through the Companion HTTP API.', title: 'Companion — press a button',
automation: { 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', title: 'Companion button press',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
// Companion HTTP API: /api/location/<page>/<row>/<column>/press // Companion HTTP API: /api/location/<page>/<row>/<column>/press
outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/0/press' }], outputs: [{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` }],
}, }),
triggers: [Cycle.onStart],
}, },
{ {
id: 'vmix-overlay-warning', 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.', description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
automation: { 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', title: 'vMix overlay on warning',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [{ type: 'http', url: 'http://127.0.0.1:8088/api/?Function=OverlayInput1In' }], outputs: [{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` }],
}, }),
triggers: [Cycle.onWarning],
}, },
{ {
id: 'webhook-event-title', id: 'webhook-event-title',
description: 'Posts the running event title to any URL. A good place to see template strings at work.', title: 'Webhook — post the running event',
automation: { 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', title: 'Webhook with the current event',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [{ type: 'http', url: 'http://127.0.0.1:3000/now?title={{eventNow.title}}' }], outputs: [{ type: 'http', url: withQuery(url, 'title={{eventNow.title}}') }],
}, }),
triggers: [Cycle.onStart],
}, },
]; ];
/** 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,6 +1,14 @@
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types'; import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { lifecycleLabels } from '../../../../common/constants/timerLifecycle'; 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. * Outputs are a union, so react-hook-form cannot resolve a field's error by name.