mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-12 09:39:37 +00:00
refactor(automation): trim recipe scaffolding
This commit is contained in:
@@ -80,7 +80,6 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
|||||||
[syncedTriggers],
|
[syncedTriggers],
|
||||||
);
|
);
|
||||||
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
|
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
|
||||||
// both are deduped, so equal lengths and one being a subset makes them the same selection
|
|
||||||
const cyclesAreDirty =
|
const cyclesAreDirty =
|
||||||
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.includes(cycle));
|
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.includes(cycle));
|
||||||
|
|
||||||
@@ -88,10 +87,6 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
|||||||
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
|
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* A lifecycle can carry several differently named triggers, which the chips collapse into one.
|
|
||||||
* Unchecking it removes all of them, so say which ones rather than deleting them quietly.
|
|
||||||
*/
|
|
||||||
const triggersToRemove = syncedTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
|
const triggersToRemove = syncedTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,7 +264,6 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** describes a filter in plain language so the user does not have to read the form back to themselves */
|
|
||||||
const describeFilter = (index: number): string | null => {
|
const describeFilter = (index: number): string | null => {
|
||||||
const field = watch(`filters.${index}.field`);
|
const field = watch(`filters.${index}.field`);
|
||||||
if (!field) {
|
if (!field) {
|
||||||
|
|||||||
-14
@@ -1,5 +1,3 @@
|
|||||||
/* ---------- step one: pick a recipe ---------- */
|
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -7,7 +5,6 @@
|
|||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** outside the scrolling list, so it stays put however many recipes there are */
|
|
||||||
.search {
|
.search {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -15,10 +12,6 @@
|
|||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Caps the list rather than fixing its height, so the dialog still shrinks to two results
|
|
||||||
* when a search narrows it down.
|
|
||||||
*/
|
|
||||||
.listViewport {
|
.listViewport {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: min(52vh, 30rem);
|
max-height: min(52vh, 30rem);
|
||||||
@@ -27,7 +20,6 @@
|
|||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
// room for the overlay scrollbar beside the chevrons
|
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +113,6 @@
|
|||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- step two: answer what the recipe cannot know ---------- */
|
|
||||||
|
|
||||||
.setup {
|
.setup {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -136,7 +126,6 @@
|
|||||||
color: $secondary-text-gray;
|
color: $secondary-text-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** the two facts a recipe decides for you, stated before the fields you can change */
|
|
||||||
.summary {
|
.summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 5rem 1fr;
|
grid-template-columns: 5rem 1fr;
|
||||||
@@ -161,7 +150,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* three columns, so a recipe's small numeric fields fill a row instead of leaving a hole */
|
|
||||||
.fields {
|
.fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -173,7 +161,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* an address or a sentence, which a third of a row cannot hold */
|
|
||||||
.wide {
|
.wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -190,7 +177,6 @@
|
|||||||
color: $secondary-text-gray;
|
color: $secondary-text-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* pushes the leading action away from the confirming ones */
|
|
||||||
.apart {
|
.apart {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-13
@@ -19,7 +19,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
|||||||
import {
|
import {
|
||||||
defaultValues,
|
defaultValues,
|
||||||
getAvailableRecipes,
|
getAvailableRecipes,
|
||||||
needsTarget,
|
|
||||||
recipeCategoryLabels,
|
recipeCategoryLabels,
|
||||||
recipeCategoryOrder,
|
recipeCategoryOrder,
|
||||||
type AutomationRecipe,
|
type AutomationRecipe,
|
||||||
@@ -34,18 +33,10 @@ const availableRecipes = getAvailableRecipes(Boolean(isOntimeCloud));
|
|||||||
|
|
||||||
interface NewAutomationDialogProps {
|
interface NewAutomationDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** hands over to the full automation form for someone who wants to start empty */
|
|
||||||
onStartEmpty: () => void;
|
onStartEmpty: () => void;
|
||||||
onCreated: (automation: Automation) => void;
|
onCreated: (automation: Automation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, onStartEmpty, onCreated }: NewAutomationDialogProps) {
|
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
|
||||||
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
|
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
|
||||||
|
|
||||||
@@ -56,7 +47,6 @@ export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** matches on everything the user might type: the software, its protocol, the job it does */
|
|
||||||
function matches(recipe: AutomationRecipe, query: string): boolean {
|
function matches(recipe: AutomationRecipe, query: string): boolean {
|
||||||
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
|
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -274,9 +264,7 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Panel.Description>
|
<Panel.Description>
|
||||||
{needsTarget(recipe)
|
Created as a normal automation, which you can edit or delete like any other.
|
||||||
? '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.'}
|
|
||||||
</Panel.Description>
|
</Panel.Description>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-141
@@ -1,106 +1,24 @@
|
|||||||
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
|
import { automationRecipes, defaultValues, getAvailableRecipes, validateRecipeValues } from '../automationRecipes';
|
||||||
|
|
||||||
import {
|
function buildOutputs(id: string, values: Record<string, string>): string {
|
||||||
automationRecipes,
|
const recipe = automationRecipes.find((candidate) => candidate.id === id);
|
||||||
defaultValues,
|
return JSON.stringify(recipe?.build(values).outputs);
|
||||||
getAvailableRecipes,
|
}
|
||||||
needsTarget,
|
|
||||||
recipeCategoryOrder,
|
|
||||||
validateRecipeValues,
|
|
||||||
} from '../automationRecipes';
|
|
||||||
import { operators } from '../automationUtils';
|
|
||||||
|
|
||||||
/**
|
describe('automation recipes', () => {
|
||||||
* Recipes are shipped as constants but created through the same endpoint as a hand written
|
it('builds a valid default automation for every recipe', () => {
|
||||||
* automation. These assertions stand in for the server side validation, so a recipe cannot
|
for (const recipe of automationRecipes) {
|
||||||
* silently rot into something that 400s when the user presses create.
|
expect(recipe.triggers).not.toEqual([]);
|
||||||
*/
|
expect(recipe.build(defaultValues(recipe))).toMatchObject({
|
||||||
describe('automationRecipes', () => {
|
title: expect.any(String),
|
||||||
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
|
filterRule: 'all',
|
||||||
|
filters: [],
|
||||||
it('has unique ids', () => {
|
|
||||||
const ids = automationRecipes.map(({ id }) => id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
});
|
});
|
||||||
|
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
|
||||||
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', () => {
|
it('hides recipes that require the local network in Ontime Cloud', () => {
|
||||||
for (const { recipe } of built) {
|
|
||||||
expect(recipe.triggers.length).toBeGreaterThan(0);
|
|
||||||
for (const cycle of recipe.triggers) {
|
|
||||||
expect(timerLifecycleValues).toContain(cycle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 automation.outputs) {
|
|
||||||
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only uses filter operators the server accepts', () => {
|
|
||||||
const allowed = operators.map(({ value }) => value);
|
|
||||||
for (const { automation } of built) {
|
|
||||||
for (const filter of automation.filters) {
|
|
||||||
expect(allowed).toContain(filter.operator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults every external target to this machine', () => {
|
|
||||||
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, automation } of built) {
|
|
||||||
const reachesOut = automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
|
|
||||||
expect(needsTarget(recipe)).toBe(reachesOut);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides local-network recipes in Ontime Cloud', () => {
|
|
||||||
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
|
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
|
||||||
'ontime-aux-timer',
|
'ontime-aux-timer',
|
||||||
'ontime-aux-stop',
|
'ontime-aux-stop',
|
||||||
@@ -111,58 +29,21 @@ describe('automationRecipes', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates recipe addresses and numeric bounds', () => {
|
it('accepts a single-label hostname for OSC', () => {
|
||||||
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
|
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
|
||||||
const companion = automationRecipes.find(({ id }) => id === 'companion-press');
|
|
||||||
const vmix = automationRecipes.find(({ id }) => id === 'vmix-overlay-warning');
|
|
||||||
const webhook = automationRecipes.find(({ id }) => id === 'webhook-event-title');
|
|
||||||
|
|
||||||
expect(qlab && validateRecipeValues(qlab, { ip: 'not a host', port: '70000' })).toEqual({
|
expect(qlab && validateRecipeValues(qlab, { ip: 'qlab', port: '53000' })).toEqual({});
|
||||||
ip: 'Enter an IP address or hostname',
|
|
||||||
port: 'Enter a whole number from 1 to 65535',
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
companion && validateRecipeValues(companion, { host: 'localhost:8888', page: '1.5', row: '-1', column: '0' }),
|
|
||||||
).toEqual({
|
|
||||||
host: 'Enter a URL starting with http:// or https://',
|
|
||||||
page: 'Enter a whole number of 1 or more',
|
|
||||||
row: 'Enter a whole number of 0 or more',
|
|
||||||
});
|
|
||||||
expect(vmix && validateRecipeValues(vmix, { host: 'http://127.0.0.1:8088', overlay: '5' })).toEqual({
|
|
||||||
overlay: 'Enter a whole number from 1 to 4',
|
|
||||||
});
|
|
||||||
expect(webhook && validateRecipeValues(webhook, { url: 'ftp://example.com' })).toEqual({
|
|
||||||
url: 'Enter a URL starting with http:// or https://',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts every recipe default', () => {
|
it('preserves an existing webhook query and URL-encodes its event title', () => {
|
||||||
for (const recipe of automationRecipes) {
|
expect(buildOutputs('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
|
||||||
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
|
'/now?source=ontime&title={{url:eventNow.title}}',
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** the outputs a recipe builds from the given answers, as plain JSON to assert against */
|
|
||||||
function buildWith(id: string, values: Record<string, string>) {
|
|
||||||
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('marks the event title for URL-safe substitution', () => {
|
it('removes a pasted trailing slash before adding a Companion path', () => {
|
||||||
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now' })).toContain(
|
|
||||||
'title={{url:eventNow.title}}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates an address pasted with a trailing slash', () => {
|
|
||||||
expect(
|
expect(
|
||||||
buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
|
buildOutputs('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');
|
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,20 +10,15 @@ export const recipeCategoryLabels: Record<RecipeCategory, string> = {
|
|||||||
messaging: 'Webhooks and messaging',
|
messaging: 'Webhooks and messaging',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** presentation order, empty categories are not rendered */
|
|
||||||
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
|
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
|
||||||
|
|
||||||
export type RecipeParam = {
|
export type RecipeParam = {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** one line under the field, for anything the label cannot say */
|
|
||||||
hint?: string;
|
hint?: string;
|
||||||
type?: 'text' | 'number' | 'choice';
|
type?: 'text' | 'number' | 'choice';
|
||||||
/** required by 'choice', which renders a select rather than a free field */
|
|
||||||
options?: { value: string; label: string }[];
|
options?: { value: string; label: string }[];
|
||||||
/** takes a whole row: addresses and free text read badly in a narrow column */
|
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
/** every default points at this machine, so a recipe cannot reach a venue network unasked */
|
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
validation?: { kind: 'host' } | { kind: 'url' } | { kind: 'integer'; min: number; max?: number };
|
validation?: { kind: 'host' } | { kind: 'url' } | { kind: 'integer'; min: number; max?: number };
|
||||||
};
|
};
|
||||||
@@ -31,20 +26,14 @@ export type RecipeParam = {
|
|||||||
export type RecipeValues = Record<string, string>;
|
export type RecipeValues = Record<string, string>;
|
||||||
|
|
||||||
export type AutomationRecipe = {
|
export type AutomationRecipe = {
|
||||||
/** stable, client only. Never persisted */
|
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
/** one line, plain language: what this does for the user */
|
|
||||||
description: string;
|
description: string;
|
||||||
category: RecipeCategory;
|
category: RecipeCategory;
|
||||||
/** requires access to software on the same network as the Ontime server */
|
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
/** extra search terms: other names for the software, its protocol, the job it does */
|
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
/** what the dialog asks for. Empty when the recipe needs nothing */
|
|
||||||
params: RecipeParam[];
|
params: RecipeParam[];
|
||||||
triggers: TimerLifeCycle[];
|
triggers: TimerLifeCycle[];
|
||||||
/** typed, so the compiler catches a recipe drifting from the automation schema */
|
|
||||||
build: (values: RecipeValues) => AutomationDTO;
|
build: (values: RecipeValues) => AutomationDTO;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,12 +59,10 @@ 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 auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
|
||||||
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } 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 {
|
function origin(value: string): string {
|
||||||
return value.trim().replace(/\/+$/, '');
|
return value.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** the recipe cannot know whether the user's URL already carries a query */
|
|
||||||
function withQuery(url: string, query: string): string {
|
function withQuery(url: string, query: string): string {
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
return trimmed.includes('?') ? `${trimmed}&${query}` : `${trimmed}?${query}`;
|
return trimmed.includes('?') ? `${trimmed}&${query}` : `${trimmed}?${query}`;
|
||||||
@@ -215,7 +202,6 @@ export const automationRecipes: AutomationRecipe[] = [
|
|||||||
triggers: [Cycle.onStart],
|
triggers: [Cycle.onStart],
|
||||||
build: ({ host, page, row, column }) =>
|
build: ({ host, page, row, column }) =>
|
||||||
buildUnfilteredAutomation('Companion button press', [
|
buildUnfilteredAutomation('Companion button press', [
|
||||||
// Companion HTTP API: /api/location/<page>/<row>/<column>/press
|
|
||||||
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
|
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
@@ -273,7 +259,6 @@ export const automationRecipes: AutomationRecipe[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** the values the dialog starts with, so a recipe can be created without touching a field */
|
|
||||||
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
|
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
|
||||||
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
|
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
|
||||||
}
|
}
|
||||||
@@ -324,8 +309,8 @@ function isHost(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
|
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
|
||||||
const fqdn = /^(?=.{1,253}$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i;
|
const hostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
|
||||||
if (ipv4.test(value) || fqdn.test(value)) {
|
if (ipv4.test(value) || hostname.test(value)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,11 +320,3 @@ function isHost(value: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user