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
This commit is contained in:
Claude
2026-09-06 19:12:32 +00:00
parent 2ddd496c78
commit 126abcbd93
6 changed files with 392 additions and 271 deletions
@@ -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 { .outerColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
color: $ui-white; color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
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;
@@ -28,6 +28,7 @@ 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';
@@ -291,7 +292,8 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
size='wide' size='wide'
title={isEdit ? 'Edit automation' : 'Create automation'} title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={ bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Automation options</h3> <h3>Automation options</h3>
<div className={style.titleSection}> <div className={style.titleSection}>
@@ -331,8 +333,8 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
</Panel.InlineElements> </Panel.InlineElements>
{hasContinuousCycle && ( {hasContinuousCycle && (
<Panel.Description tone='warning'> <Panel.Description tone='warning'>
Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you mean Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
to send on every tick. mean to send on every tick.
</Panel.Description> </Panel.Description>
)} )}
{triggersToRemove.length > 0 && ( {triggersToRemove.length > 0 && (
@@ -519,6 +521,7 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</ScrollArea>
</form> </form>
} }
footerElements={ footerElements={
@@ -7,16 +7,28 @@
color: $ui-white; color: $ui-white;
} }
/** stays in view while the list scrolls under it, which is the point of having a search */ /** outside the scrolling list, so it stays put however many recipes there are */
.search { .search {
position: sticky; position: relative;
top: -0.5rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
padding-block: 0.5rem; padding-block: 0.5rem;
margin-top: -0.5rem; }
background-color: $gray-1250;
/**
* 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 { .searchIcon {
@@ -41,6 +53,10 @@
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
padding-top: 0.75rem; padding-top: 0.75rem;
&:first-child {
padding-top: 0;
}
} }
.groupTitle { .groupTitle {
@@ -8,6 +8,8 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal'; 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 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';
@@ -141,6 +143,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
/> />
)} )}
<ScrollArea viewportClassName={style.listViewport} contentClassName={style.list}>
{recipeCategoryOrder.map((category) => { {recipeCategoryOrder.map((category) => {
const inCategory = results.filter((recipe) => recipe.category === category); const inCategory = results.filter((recipe) => recipe.category === category);
if (inCategory.length === 0) { if (inCategory.length === 0) {
@@ -167,6 +170,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
</section> </section>
); );
})} })}
</ScrollArea>
</div> </div>
} }
footerElements={ footerElements={
@@ -196,6 +200,8 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
const automation = recipe.build(values); const automation = recipe.build(values);
const isComplete = recipe.params.every(({ name }) => values[name]?.trim()); const isComplete = recipe.params.every(({ name }) => values[name]?.trim());
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
const handleCreate = async () => { const handleCreate = async () => {
setError(null); setError(null);
setIsCreating(true); setIsCreating(true);
@@ -249,16 +255,29 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
{recipe.params.length > 0 && ( {recipe.params.length > 0 && (
<div className={style.fields}> <div className={style.fields}>
{recipe.params.map(({ name, label, hint, type, wide }) => ( {recipe.params.map((param) => (
<label key={name} className={cx([style.field, wide && style.wide])}> <label key={param.name} className={cx([style.field, param.wide && style.wide])}>
{label} {param.label}
<Input {param.type === 'choice' ? (
type={type === 'number' ? 'number' : 'text'} <Select
value={values[name]} value={values[param.name]}
onChange={(event) => setValues((prev) => ({ ...prev, [name]: event.target.value }))} onValueChange={(value: string | null) => {
if (value === null) return;
setValue(param.name, value);
}}
options={param.options ?? []}
aria-label={param.label}
fluid fluid
/> />
{hint && <span className={style.hint}>{hint}</span>} ) : (
<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> </label>
))} ))}
</div> </div>
@@ -42,11 +42,23 @@ describe('automationRecipes', () => {
} }
}); });
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', () => { it('reads every parameter it declares', () => {
// a param the builder ignores is a field the user fills in for nothing, and a typo in // 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 // either half would put the literal 'undefined' inside a URL
for (const { recipe } of built) { for (const { recipe } of built) {
for (const param of recipe.params) { 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 marker = param.type === 'number' ? '4242' : 'ontime-probe';
const probed = { ...defaultValues(recipe), [param.name]: marker }; const probed = { ...defaultValues(recipe), [param.name]: marker };
expect(JSON.stringify(recipe.build(probed))).toContain(marker); expect(JSON.stringify(recipe.build(probed))).toContain(marker);
@@ -4,7 +4,7 @@ import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging'; export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
export const recipeCategoryLabels: Record<RecipeCategory, string> = { export const recipeCategoryLabels: Record<RecipeCategory, string> = {
ontime: 'Works out of the box', ontime: 'Ontime automations',
playback: 'Playback and cue systems', playback: 'Playback and cue systems',
video: 'Video and streaming', video: 'Video and streaming',
messaging: 'Webhooks and messaging', messaging: 'Webhooks and messaging',
@@ -18,7 +18,9 @@ export type RecipeParam = {
label: string; label: string;
/** one line under the field, for anything the label cannot say */ /** one line under the field, for anything the label cannot say */
hint?: string; hint?: string;
type?: 'text' | 'number'; 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 */ /** takes a whole row: addresses and free text read badly in a narrow column */
wide?: boolean; wide?: boolean;
/** every default points at this machine, so a recipe cannot reach a venue network unasked */ /** every default points at this machine, so a recipe cannot reach a venue network unasked */
@@ -43,6 +45,28 @@ export type AutomationRecipe = {
build: (values: RecipeValues) => AutomationDTO; 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 */ /** a user pasting an address is as likely to include the trailing slash as not */
function origin(value: string): string { function origin(value: string): string {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
@@ -58,21 +82,39 @@ export const automationRecipes: AutomationRecipe[] = [
{ {
id: 'ontime-aux-timer', id: 'ontime-aux-timer',
title: 'Run an aux timer with the event', title: 'Run an aux timer with the event',
description: 'Sets aux timer 1 and starts it whenever an event starts.', description: 'Sets an aux timer and starts it whenever an event starts.',
category: 'ontime', category: 'ontime',
keywords: ['countdown', 'stage timer', 'speaker'], keywords: ['countdown', 'stage timer', 'speaker'],
params: [{ name: 'duration', label: 'Duration', hint: 'hh:mm:ss', defaultValue: '00:05:00' }], 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], triggers: [Cycle.onStart],
build: ({ duration }) => ({ build: ({ aux, duration }) => ({
title: 'Run Aux Timer 1 with the event', title: `Run Aux Timer ${toAux(aux)} with the event`,
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [ outputs: [
{ type: 'ontime', action: 'aux1-set', time: duration.trim() }, { type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
{ type: 'ontime', action: 'aux1-start' }, { 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', id: 'ontime-warn-stage',
title: 'Warn the stage when time runs low', title: 'Warn the stage when time runs low',
@@ -103,6 +145,21 @@ export const automationRecipes: AutomationRecipe[] = [
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }], 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', id: 'qlab-go',
title: 'QLab — fire the matching cue', title: 'QLab — fire the matching cue',