diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss index d258c04b3..e2a3da972 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss @@ -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 { display: flex; flex-direction: column; gap: 2rem; font-size: calc(1rem - 1px); color: $ui-white; - - // the shared modal body owns scrolling for this regular form modal - min-height: 100%; padding-block: 0.5rem; + // leaves the overlay scrollbar somewhere to sit without covering a field + padding-right: 0.5rem; h3 { font-size: 1rem; 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 fabc7797c..d0dafbffe 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 @@ -28,6 +28,7 @@ import Input from '../../../../common/components/input/input/Input'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import Modal from '../../../../common/components/modal/Modal'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; +import ScrollArea from '../../../../common/components/scroll-area/ScrollArea'; import Select from '../../../../common/components/select/Select'; import Tag from '../../../../common/components/tag/Tag'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; @@ -291,234 +292,236 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa size='wide' title={isEdit ? 'Edit automation' : 'Create automation'} bodyElements={ -
-
-

Automation options

-
- - {errors.title?.message} -
- -
- - - Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a - single event from the event editor. - - - {cycles.map(({ id, label, value }) => { - const cycle = value as TimerLifeCycle; - const isSelected = selectedCycles.includes(cycle); - return ( - - ); - })} - - {hasContinuousCycle && ( - - Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you mean - to send on every tick. - - )} - {triggersToRemove.length > 0 && ( - - {`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove - .map((trigger) => trigger.title) - .join(', ')}`} - - )} -
-
- -
-

Filters (optional)

- - Without filters the outputs are sent every time the automation is triggered. - -
- {fieldFilters.length > 1 && ( + + +
+

Automation options

+
- )} - {fieldFilters.map((field, index) => { - const description = describeFilter(index); - return ( -
-
- Filter - {description} - removeFilter(index)} + {errors.title?.message} +
+ +
+ + + Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a + single event from the event editor. + + + {cycles.map(({ id, label, value }) => { + const cycle = value as TimerLifeCycle; + const isSelected = selectedCycles.includes(cycle); + return ( +
-
- - -
-
- ); - })} -
- + {label} + + ); + })} + + {hasContinuousCycle && ( + + Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you + mean to send on every tick. + + )} + {triggersToRemove.length > 0 && ( + + {`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove + .map((trigger) => trigger.title) + .join(', ')}`} + + )}
-
-
-

Outputs

- - Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '} - read the docs - - - {fieldOutputs.length === 0 && ( - - )} - - {fieldOutputs.map((output, index) => { - const rowErrors = getOutputErrors(index); - const cardProps = { - testState: testResults[output.id], - onTest: () => handleTest(index, output.id), - onDelete: () => removeOutput(index), - }; - - if (isOSCOutput(output)) { - return ( - - - - ); - } - - if (isHTTPOutput(output)) { - return ( - - - - ); - } - - if (isOntimeAction(output)) { - return ( - - +

Filters (optional)

+ + Without filters the outputs are sent every time the automation is triggered. + +
+ {fieldFilters.length > 1 && ( +
-
+ +
+

Outputs

+ + Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '} + read the docs + + + {fieldOutputs.length === 0 && ( + + )} + + {fieldOutputs.map((output, index) => { + const rowErrors = getOutputErrors(index); + const cardProps = { + testState: testResults[output.id], + onTest: () => handleTest(index, output.id), + onDelete: () => removeOutput(index), + }; + + if (isOSCOutput(output)) { + return ( + + + + ); + } + + if (isHTTPOutput(output)) { + return ( + + + + ); + } + + if (isOntimeAction(output)) { + return ( + + + + ); + } + + return null; + })} +
+ } + items={[ + { + type: 'item', + label: 'OSC', + description: 'Send an OSC message to a device on the network', + onClick: handleAddNewOSCOutput, + }, + { + type: 'item', + label: 'HTTP', + description: 'Call a URL, for webhooks and REST APIs', + onClick: handleAddNewHTTPOutput, + }, + { + type: 'item', + label: 'Ontime action', + description: 'Change something inside Ontime, like a message or an aux timer', + onClick: handleAddnewOntimeAction, + }, + ]} + > + Add output + +
+
+
} 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) => ( - - ))} -
- ); - })} + return ( +
+

{recipeCategoryLabels[category]}

+ {inCategory.map((recipe) => ( + + ))} +
+ ); + })} +
} 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 }) => ( -
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',