-
Filters (optional)
-
- Without filters the outputs are sent every time the automation is triggered.
-
-
- {fieldFilters.length > 1 && (
+
}
footerElements={
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss
index 15bf24778..3d0198f09 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss
+++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss
@@ -7,16 +7,28 @@
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 {
- position: sticky;
- top: -0.5rem;
- z-index: 1;
+ position: relative;
display: flex;
align-items: center;
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 {
@@ -41,6 +53,10 @@
flex-direction: column;
gap: 0.25rem;
padding-top: 0.75rem;
+
+ &:first-child {
+ padding-top: 0;
+ }
}
.groupTitle {
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx
index dad805fca..24539e153 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx
+++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx
@@ -8,6 +8,8 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
+import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
+import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
@@ -141,32 +143,34 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
/>
)}
- {recipeCategoryOrder.map((category) => {
- const inCategory = results.filter((recipe) => recipe.category === category);
- if (inCategory.length === 0) {
- return null;
- }
+
+ {recipeCategoryOrder.map((category) => {
+ const inCategory = results.filter((recipe) => recipe.category === category);
+ if (inCategory.length === 0) {
+ return null;
+ }
- return (
-
- {recipeCategoryLabels[category]}
- {inCategory.map((recipe) => (
- onSelect(recipe)}>
-
-
{recipe.title}
-
{recipe.description}
-
-
- {recipe.triggers.map((cycle) => (
- {getLifecycleLabel(cycle)}
- ))}
-
-
-
- ))}
-
- );
- })}
+ return (
+
+ {recipeCategoryLabels[category]}
+ {inCategory.map((recipe) => (
+ onSelect(recipe)}>
+
+
{recipe.title}
+
{recipe.description}
+
+
+ {recipe.triggers.map((cycle) => (
+ {getLifecycleLabel(cycle)}
+ ))}
+
+
+
+ ))}
+
+ );
+ })}
+
}
footerElements={
@@ -196,6 +200,8 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
const automation = recipe.build(values);
const isComplete = recipe.params.every(({ name }) => values[name]?.trim());
+ const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
+
const handleCreate = async () => {
setError(null);
setIsCreating(true);
@@ -249,16 +255,29 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
{recipe.params.length > 0 && (
- {recipe.params.map(({ name, label, hint, type, wide }) => (
-
- {label}
- setValues((prev) => ({ ...prev, [name]: event.target.value }))}
- fluid
- />
- {hint && {hint} }
+ {recipe.params.map((param) => (
+
+ {param.label}
+ {param.type === 'choice' ? (
+ {
+ if (value === null) return;
+ setValue(param.name, value);
+ }}
+ options={param.options ?? []}
+ aria-label={param.label}
+ fluid
+ />
+ ) : (
+ setValue(param.name, event.target.value)}
+ fluid
+ />
+ )}
+ {param.hint && {param.hint} }
))}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts
index 203372e18..506bba0da 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts
+++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts
@@ -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', () => {
// a param the builder ignores is a field the user fills in for nothing, and a typo in
// either half would put the literal 'undefined' inside a URL
for (const { recipe } of built) {
for (const param of recipe.params) {
+ // a choice can only take one of its own options, so probe with the last one
+ if (param.type === 'choice') {
+ const last = param.options?.at(-1)?.value ?? '';
+ expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last);
+ continue;
+ }
const marker = param.type === 'number' ? '4242' : 'ontime-probe';
const probed = { ...defaultValues(recipe), [param.name]: marker };
expect(JSON.stringify(recipe.build(probed))).toContain(marker);
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts
index 262518c1e..bb0f571dc 100644
--- a/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts
+++ b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts
@@ -4,7 +4,7 @@ import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
export const recipeCategoryLabels: Record
= {
- ontime: 'Works out of the box',
+ ontime: 'Ontime automations',
playback: 'Playback and cue systems',
video: 'Video and streaming',
messaging: 'Webhooks and messaging',
@@ -18,7 +18,9 @@ export type RecipeParam = {
label: string;
/** one line under the field, for anything the label cannot say */
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 */
wide?: boolean;
/** 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;
};
+const auxTimers = [
+ { value: '1', label: 'Aux timer 1' },
+ { value: '2', label: 'Aux timer 2' },
+ { value: '3', label: 'Aux timer 3' },
+];
+
+type AuxNumber = '1' | '2' | '3';
+
+/**
+ * Action keys are a union the compiler checks against the automation schema, so the aux
+ * number is resolved through maps rather than string interpolation. Anything unexpected
+ * falls back to the first timer instead of building an action the server would reject.
+ */
+function toAux(value: string): AuxNumber {
+ return value === '2' || value === '3' ? value : '1';
+}
+
+const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
+const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
+const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
+const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
+
/** a user pasting an address is as likely to include the trailing slash as not */
function origin(value: string): string {
return value.trim().replace(/\/+$/, '');
@@ -58,21 +82,39 @@ export const automationRecipes: AutomationRecipe[] = [
{
id: 'ontime-aux-timer',
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',
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],
- build: ({ duration }) => ({
- title: 'Run Aux Timer 1 with the event',
+ build: ({ aux, duration }) => ({
+ title: `Run Aux Timer ${toAux(aux)} with the event`,
filterRule: 'all',
filters: [],
outputs: [
- { type: 'ontime', action: 'aux1-set', time: duration.trim() },
- { type: 'ontime', action: 'aux1-start' },
+ { type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
+ { type: 'ontime', action: auxStart[toAux(aux)] },
],
}),
},
+ {
+ id: 'ontime-aux-stop',
+ title: 'Stop the aux timer when the event ends',
+ description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
+ category: 'ontime',
+ keywords: ['countdown', 'stage timer', 'reset'],
+ params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
+ triggers: [Cycle.onFinish],
+ build: ({ aux }) => ({
+ title: `Stop Aux Timer ${toAux(aux)} on finish`,
+ filterRule: 'all',
+ filters: [],
+ outputs: [{ type: 'ontime', action: auxStop[toAux(aux)] }],
+ }),
+ },
{
id: 'ontime-warn-stage',
title: 'Warn the stage when time runs low',
@@ -103,6 +145,21 @@ export const automationRecipes: AutomationRecipe[] = [
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
}),
},
+ {
+ id: 'ontime-secondary-message',
+ title: 'Show an aux timer beside the stage message',
+ description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
+ category: 'ontime',
+ keywords: ['message', 'secondary', 'stage', 'countdown'],
+ params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
+ triggers: [Cycle.onLoad],
+ build: ({ aux }) => ({
+ title: `Show Aux Timer ${toAux(aux)} as the secondary message`,
+ filterRule: 'all',
+ filters: [],
+ outputs: [{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] }],
+ }),
+ },
{
id: 'qlab-go',
title: 'QLab — fire the matching cue',