From 2ddd496c78d1acba534de9850268b57a76e99be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 19:04:33 +0000 Subject: [PATCH] feat(automation): ask a recipe for its parameters instead of its whole form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf --- .../automations-panel/AutomationForm.tsx | 17 +- .../automations-panel/AutomationsList.tsx | 42 +-- .../NewAutomationDialog.module.scss | 156 ++++++++- .../automations-panel/NewAutomationDialog.tsx | 305 +++++++++++++++--- .../__tests__/automationRecipes.test.ts | 82 +++-- .../automations-panel/automationRecipes.ts | 218 +++++++++---- .../automations-panel/automationUtils.ts | 10 +- 7 files changed, 654 insertions(+), 176 deletions(-) diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx index 2b8e18834..fabc7797c 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -33,7 +33,7 @@ import Tag from '../../../../common/components/tag/Tag'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useCustomFields from '../../../../common/hooks-query/useCustomFields'; 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 OntimeActionForm from './OntimeActionForm'; import OscOutputForm from './OscOutputForm'; @@ -54,12 +54,10 @@ interface AutomationFormProps { automation: Automation | AutomationDTO; /** global triggers, used to resolve which lifecycles this automation is currently bound to */ triggers: Trigger[]; - /** lifecycles a new automation starts with selected, used by recipes */ - defaultCycles?: TimerLifeCycle[]; onClose: () => void; } -export default function AutomationForm({ automation, triggers, defaultCycles, onClose }: AutomationFormProps) { +export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) { const isEdit = isAutomation(automation); const { data } = useCustomFields(); const { refetch } = useAutomationSettings(); @@ -79,10 +77,7 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on () => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))), [initialTriggers], ); - // a new automation can arrive pre-filled from a recipe, an existing one resolves its own triggers - const [selectedCycles, setSelectedCycles] = useState( - isEdit ? initialCycles : (defaultCycles ?? []), - ); + const [selectedCycles, setSelectedCycles] = useState(initialCycles); /** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */ const [createdId, setCreatedId] = useState(null); @@ -233,8 +228,7 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle)); for (const cycle of toAdd) { - const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle; - await addTrigger({ title: `${title} — ${label}`, trigger: cycle, automationId }); + await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId }); } }; @@ -285,8 +279,7 @@ export default function AutomationForm({ automation, triggers, defaultCycles, on return `${fieldLabel} ${operatorLabel} ${value ? `“${value}”` : 'nothing'}`; }; - // a recipe arrives complete, so a new automation is savable without the user changing anything - const canSubmit = !isSubmitting && (!isEdit || isDirty || cyclesAreDirty) && isValid; + const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && isValid; const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle)); return ( diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index 649927881..42fb3e47b 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -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 { 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 useAppSettingsNavigation from '../../useAppSettingsNavigation'; import AutomationForm from './AutomationForm'; -import type { AutomationRecipe } from './automationRecipes'; import { groupTriggersByAutomation, isAutomation } from './automationUtils'; import DeleteAutomationDialog from './DeleteAutomationDialog'; import NewAutomationDialog from './NewAutomationDialog'; @@ -27,13 +26,6 @@ const emptyAutomation: AutomationDTO = { 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 { automations: NormalisedAutomation; triggers: Trigger[]; @@ -49,17 +41,22 @@ export default function AutomationsList({ }: AutomationsListProps) { const { refetch } = useAutomationSettings(); const { setLocation } = useAppSettingsNavigation(); - const [formState, setFormState] = useState(null); + const [editing, setEditing] = useState(null); const [isPickingStart, setIsPickingStart] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); const automationIds = Object.keys(automations); - /** a recipe is only ever a pre-filled form, nothing is written until the user saves */ - const handleStartFrom = (recipe: AutomationRecipe | null) => { + /** a recipe creates the automation itself, so it lands in the list rather than in a form */ + const handleCreated = async () => { setIsPickingStart(false); - setFormState({ automation: recipe?.automation ?? emptyAutomation, defaultCycles: recipe?.triggers }); + await refetch(); + }; + + const handleStartEmpty = () => { + setIsPickingStart(false); + setEditing(emptyAutomation); }; const handleDeleted = async () => { @@ -70,18 +67,23 @@ export default function AutomationsList({ return ( - {formState !== null && ( + {editing !== null && ( setFormState(null)} + onClose={() => setEditing(null)} + /> + )} + {isPickingStart && ( + setIsPickingStart(false)} + onStartEmpty={handleStartEmpty} + onCreated={handleCreated} /> )} - {isPickingStart && setIsPickingStart(false)} onSelect={handleStartFrom} />} {deleteTarget !== null && ( setFormState({ automation })} + onClick={() => setEditing(automation)} > 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 3f36e7bd6..15bf24778 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 @@ -1,58 +1,180 @@ -.list { +/* ---------- step one: pick a recipe ---------- */ + +.picker { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.25rem; color: $ui-white; } -.listLabel { - margin-top: 0.5rem; - font-size: $aux-text-size; - color: $secondary-text-gray; +/** stays in view while the list scrolls under it, which is the point of having a search */ +.search { + position: sticky; + 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; align-items: center; gap: 0.75rem; width: 100%; - padding: 0.75rem; + padding: 0.625rem 0.75rem; text-align: left; color: inherit; - background-color: $black-10; - border: 1px solid $white-10; + background-color: transparent; + border: 1px solid transparent; border-radius: $component-border-radius-md; cursor: pointer; &:hover { background-color: $white-3; - border-color: $white-20; + border-color: $white-10; } &:focus-visible { outline: 1px solid $action-blue; - outline-offset: 1px; + outline-offset: -1px; } } -.optionText { +.recipeText { display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.125rem; flex: 1; min-width: 0; } -.optionTitle { +.recipeTitle { font-weight: 600; } -.optionDescription { +.recipeDescription { font-size: $aux-text-size; color: $secondary-text-gray; } -.chevron { +.recipeTags { + display: flex; + align-items: center; + gap: 0.5rem; flex-shrink: 0; +} + +.chevron { + color: $gray-400; +} + +/* ---------- step two: answer what the recipe cannot know ---------- */ + +.setup { + display: flex; + flex-direction: column; + gap: 1rem; + color: $ui-white; + padding-block: 0.25rem; +} + +.setupDescription { + margin: 0; color: $secondary-text-gray; } + +/** the two facts a recipe decides for you, stated before the fields you can change */ +.summary { + display: grid; + grid-template-columns: 5rem 1fr; + align-items: center; + gap: 0.5rem 0.75rem; + margin: 0; + padding: 0.75rem; + background-color: $black-10; + border: 1px solid $white-10; + border-radius: $component-border-radius-md; + + dt { + font-size: $aux-text-size; + color: $label-gray; + } + + dd { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + } +} + +/* three columns, so a recipe's small numeric fields fill a row instead of leaving a hole */ +.fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: start; + gap: 0.75rem; + + @media (width < 40rem) { + grid-template-columns: repeat(2, 1fr); + } +} + +/* an address or a sentence, which a third of a row cannot hold */ +.wide { + grid-column: 1 / -1; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: $aux-text-size; + color: $label-gray; +} + +.hint { + color: $secondary-text-gray; +} + +/* pushes the leading action away from the confirming ones */ +.apart { + margin-right: auto; +} 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 cfb50568b..dad805fca 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 @@ -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 Tag from '../../../../common/components/tag/Tag'; import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; import { summariseOutputs } from '../../../../common/utils/automationOutputs'; +import { cx } from '../../../../common/utils/styleUtils'; import { isOntimeCloud } from '../../../../externals'; import * as Panel from '../../panel-utils/PanelUtils'; -import { automationRecipes, 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'; interface NewAutomationDialogProps { onClose: () => void; - /** called with the recipe to pre-fill the form with, or null to start from an empty one */ - onSelect: (recipe: AutomationRecipe | null) => void; + /** hands over to the full automation form for someone who wants to start empty */ + onStartEmpty: () => void; + onCreated: (automation: Automation) => void; } /** - * The single entry point for making an automation: a list of starting points. - * 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. + * The single entry point for making an automation. + * + * Two steps in one dialog rather than two stacked ones: pick a recipe, then answer only + * what that recipe cannot know — where your gear is, how long the timer runs. Everything + * else the recipe already decided, which is the point of having recipes at all. */ -export default function NewAutomationDialog({ onClose, onSelect }: NewAutomationDialogProps) { - // OSC is not available in the cloud service, offering those recipes there would be a lie - const recipes = isOntimeCloud - ? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc')) - : automationRecipes; +export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) { + const [selected, setSelected] = useState(null); + + return selected === null ? ( + + ) : ( + 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) => { + // 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 ( - - -
- Or start from a recipe. Each one opens as a normal automation you can edit before saving. +
+
+ + setQuery(event.target.value)} + onKeyDown={handleSearchKey} + placeholder='Search recipes, eg. QLab, OSC, message' + className={style.searchInput} + aria-label='Search recipes' + fluid + autoFocus + /> + {trimmed.length > 0 && ( + setQuery('')} + > + + + )}
- {recipes.map((recipe) => ( - - ))} + {results.length === 0 && ( + + )} + + {recipeCategoryOrder.map((category) => { + const inCategory = results.filter((recipe) => recipe.category === category); + if (inCategory.length === 0) { + return null; + } + + return ( +
+

{recipeCategoryLabels[category]}

+ {inCategory.map((recipe) => ( + + ))} +
+ ); + })}
} + footerElements={ + <> + + + + } + /> + ); +} + +interface RecipeSetupProps { + recipe: AutomationRecipe; + onClose: () => void; + onBack: () => void; + onCreated: (automation: Automation) => void; +} + +function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) { + const [values, setValues] = useState(() => defaultValues(recipe)); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(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 ( + +

{recipe.description}

+ +
+
Runs on
+
+ {recipe.triggers.map((cycle) => ( + {getLifecycleLabel(cycle)} + ))} +
+
Sends
+
+ {summariseOutputs(automation.outputs).map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + ))} +
+
+ + {recipe.params.length > 0 && ( +
+ {recipe.params.map(({ name, label, hint, type, wide }) => ( + + ))} +
+ )} + + + {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.'} + +
+ } + footerElements={ + <> + {error && {error}} + + + + + } /> ); } 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 1ef975e9b..203372e18 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 @@ -1,21 +1,29 @@ import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types'; -import { automationRecipes, needsTarget } from '../automationRecipes'; +import { automationRecipes, defaultValues, needsTarget, recipeCategoryOrder } from '../automationRecipes'; 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 - * silently rot into something that 400s when the user presses save. + * silently rot into something that 400s when the user presses create. */ describe('automationRecipes', () => { + const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) })); + it('has unique ids', () => { const ids = automationRecipes.map(({ id }) => id); expect(new Set(ids).size).toBe(ids.length); }); + it('only uses categories the picker knows how to render', () => { + for (const { recipe } of built) { + expect(recipeCategoryOrder).toContain(recipe.category); + } + }); + it('binds every recipe to at least one valid lifecycle', () => { - for (const recipe of automationRecipes) { + for (const { recipe } of built) { expect(recipe.triggers.length).toBeGreaterThan(0); for (const cycle of recipe.triggers) { expect(timerLifecycleValues).toContain(cycle); @@ -23,43 +31,71 @@ describe('automationRecipes', () => { } }); - it('gives every recipe a title and something to send', () => { - for (const recipe of automationRecipes) { - expect(recipe.automation.title).not.toBe(''); - expect(recipe.automation.outputs.length).toBeGreaterThan(0); + it('builds a titled automation with something to send, from its own defaults', () => { + for (const { automation } of built) { + expect(automation.title).not.toBe(''); + expect(automation.outputs.length).toBeGreaterThan(0); - for (const output of recipe.automation.outputs) { + for (const output of automation.outputs) { 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', () => { const allowed = operators.map(({ value }) => value); - for (const recipe of automationRecipes) { - for (const filter of recipe.automation.filters) { + for (const { automation } of built) { + for (const filter of automation.filters) { expect(allowed).toContain(filter.operator); } } }); it('defaults every external target to this machine', () => { - for (const recipe of automationRecipes) { - for (const output of recipe.automation.outputs) { - if (isOSCOutput(output)) { - expect(output.targetIP).toBe('127.0.0.1'); - } - if (isHTTPOutput(output)) { - expect(output.url.startsWith('http://127.0.0.1')).toBe(true); - } - } - } + const outputs = built.flatMap(({ automation }) => automation.outputs); + const osc = outputs.filter(isOSCOutput); + const http = outputs.filter(isHTTPOutput); + + // filtering rather than asserting in a branch, so a failure names the offending recipe + expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]); + expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]); + expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]); }); it('flags the recipes that reach outside Ontime', () => { - for (const recipe of automationRecipes) { - const reachesOut = recipe.automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output)); + for (const { recipe, automation } of built) { + const reachesOut = automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output)); expect(needsTarget(recipe)).toBe(reachesOut); } }); + + /** the outputs a recipe builds from the given answers, as plain JSON to assert against */ + function buildWith(id: string, values: Record) { + 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'); + }); }); 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 dc12966b0..262518c1e 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 @@ -1,119 +1,225 @@ 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 = { + 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; -/** - * 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 = { /** stable, client only. Never persisted */ id: string; + title: string; /** one line, plain language: what this does for the user */ description: string; - /** typed so the compiler catches drift against the automation schema */ - automation: AutomationDTO; - /** lifecycles the form starts with selected */ + category: RecipeCategory; + /** extra search terms: other names for the software, its protocol, the job it does */ + keywords?: string[]; + /** what the dialog asks for. Empty when the recipe needs nothing */ + params: RecipeParam[]; triggers: TimerLifeCycle[]; + /** typed, so the compiler catches a recipe drifting from the automation schema */ + build: (values: RecipeValues) => AutomationDTO; }; -/** - * A recipe that only sends Ontime actions works the moment it is saved. - * Anything else points at software we cannot locate for the user. - */ -export function needsTarget(recipe: AutomationRecipe): boolean { - return !recipe.automation.outputs.every(isOntimeAction); +/** a user pasting an address is as likely to include the trailing slash as not */ +function origin(value: string): string { + return value.trim().replace(/\/+$/, ''); +} + +/** the recipe cannot know whether the user's URL already carries a query */ +function withQuery(url: string, query: string): string { + const trimmed = url.trim(); + return trimmed.includes('?') ? `${trimmed}&${query}` : `${trimmed}?${query}`; } -/** - * 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[] = [ { id: 'ontime-aux-timer', - description: 'Sets aux timer 1 to five minutes and starts it whenever an event starts.', - automation: { - title: 'Start Aux Timer 1 with the event', + title: 'Run an aux timer with the event', + description: 'Sets aux timer 1 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' }], + triggers: [Cycle.onStart], + build: ({ duration }) => ({ + title: 'Run Aux Timer 1 with the event', filterRule: 'all', filters: [], outputs: [ - { type: 'ontime', action: 'aux1-set', time: '00:05:00' }, + { type: 'ontime', action: 'aux1-set', time: duration.trim() }, { type: 'ontime', action: 'aux1-start' }, ], - }, - triggers: [Cycle.onStart], + }), }, { id: 'ontime-warn-stage', - description: 'Shows a message on the stage timer as soon as the running event enters its danger window.', - automation: { + title: 'Warn the stage when time runs low', + description: 'Shows a message on the stage timer as the running event enters its danger window.', + category: 'ontime', + keywords: ['message', 'danger', 'wrap up', 'presenter'], + params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }], + triggers: [Cycle.onDanger], + build: ({ message }) => ({ title: 'Warn the stage at danger', filterRule: 'all', filters: [], - outputs: [{ type: 'ontime', action: 'message-set', text: 'Please wrap up', visible: true }], - }, - triggers: [Cycle.onDanger], + outputs: [{ type: 'ontime', action: 'message-set', text: message, visible: true }], + }), }, { id: 'ontime-clear-message', - description: 'Hides the stage message once the event finishes. Pairs with the danger warning above.', - automation: { + title: 'Hide the stage message on finish', + description: 'Clears the stage message once the event finishes. Pairs with the warning above.', + category: 'ontime', + keywords: ['message', 'clear', 'presenter'], + params: [], + triggers: [Cycle.onFinish], + build: () => ({ title: 'Hide the stage message on finish', filterRule: 'all', filters: [], outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }], - }, - triggers: [Cycle.onFinish], + }), }, { id: 'qlab-go', - description: "Sends OSC to QLab to start the cue whose number matches the Ontime event's cue.", - automation: { + title: 'QLab — fire the matching cue', + description: "Starts the QLab cue whose number matches the Ontime event's cue.", + category: 'playback', + keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'], + params: [ + { + name: 'ip', + label: 'QLab computer', + hint: 'IP address of the machine running QLab', + wide: true, + defaultValue: '127.0.0.1', + }, + { name: 'port', label: 'OSC port', type: 'number', hint: "QLab's default is 53000", defaultValue: '53000' }, + ], + triggers: [Cycle.onStart], + build: ({ ip, port }) => ({ title: 'QLab GO on event start', filterRule: 'all', filters: [], outputs: [ - { type: 'osc', targetIP: '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', - description: 'Presses page 1, button 1 on a Stream Deck through the Companion HTTP API.', - automation: { + title: 'Companion — press a button', + description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.', + category: 'playback', + keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'], + params: [ + { + name: 'host', + label: 'Companion address', + hint: 'Where the Companion HTTP API is listening', + wide: true, + defaultValue: 'http://127.0.0.1:8888', + }, + { name: 'page', label: 'Page', type: 'number', defaultValue: '1' }, + { name: 'row', label: 'Row', type: 'number', defaultValue: '0' }, + { name: 'column', label: 'Column', type: 'number', defaultValue: '0' }, + ], + triggers: [Cycle.onStart], + build: ({ host, page, row, column }) => ({ title: 'Companion button press', filterRule: 'all', filters: [], // Companion HTTP API: /api/location////press - outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/0/press' }], - }, - triggers: [Cycle.onStart], + outputs: [{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` }], + }), }, { id: 'vmix-overlay-warning', + title: 'vMix — show an overlay on warning', description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.', - 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', filterRule: 'all', filters: [], - outputs: [{ type: 'http', url: 'http://127.0.0.1:8088/api/?Function=OverlayInput1In' }], - }, - triggers: [Cycle.onWarning], + outputs: [{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` }], + }), }, { id: 'webhook-event-title', - description: 'Posts the running event title to any URL. A good place to see template strings at work.', - automation: { + title: 'Webhook — post the running event', + description: 'Calls any URL with the running event title, as a template string you can edit afterwards.', + category: 'messaging', + keywords: ['http', 'rest', 'api', 'integration', 'slack'], + params: [ + { + name: 'url', + label: 'URL', + hint: 'The event title is added as a title parameter', + wide: true, + defaultValue: 'http://127.0.0.1:3000/now', + }, + ], + triggers: [Cycle.onStart], + build: ({ url }) => ({ title: 'Webhook with the current event', filterRule: 'all', filters: [], - outputs: [{ type: 'http', url: 'http://127.0.0.1:3000/now?title={{eventNow.title}}' }], - }, - triggers: [Cycle.onStart], + outputs: [{ type: 'http', url: withQuery(url, 'title={{eventNow.title}}') }], + }), }, ]; + +/** the values the dialog starts with, so a recipe can be created without touching a field */ +export function defaultValues(recipe: AutomationRecipe): RecipeValues { + return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue])); +} + +/** + * A recipe that only sends Ontime actions works the moment it is created. + * Anything else points at software we cannot locate for the user. + */ +export function needsTarget(recipe: AutomationRecipe): boolean { + return !recipe.build(defaultValues(recipe)).outputs.every((output) => output.type === 'ontime'); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts index 4685ed6ea..36d41c2f1 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts @@ -1,6 +1,14 @@ 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.