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.