mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +00:00
chore: rename feature elements
rename automations > triggers rename blueprints > automations chore: add link to documentation
This commit is contained in:
committed by
Carlos Valente
parent
f4f266dbd4
commit
efe5ac16f2
@@ -1,11 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Automation,
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintDTO,
|
||||
AutomationDTO,
|
||||
AutomationOutput,
|
||||
AutomationSettings,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
@@ -30,6 +30,29 @@ export async function editAutomationSettings(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation trigger
|
||||
*/
|
||||
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
|
||||
const res = await axios.post(`${automationsPath}/trigger`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update an automation trigger
|
||||
*/
|
||||
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
|
||||
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete an automation trigger
|
||||
*/
|
||||
export function deleteTrigger(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/trigger/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation
|
||||
*/
|
||||
@@ -39,7 +62,7 @@ export async function addAutomation(automation: AutomationDTO): Promise<Automati
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update an automation
|
||||
* HTTP request to update a automation
|
||||
*/
|
||||
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
|
||||
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
|
||||
@@ -47,35 +70,12 @@ export async function editAutomation(id: string, automation: Automation): Promis
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete an automation
|
||||
* HTTP request to delete a automation
|
||||
*/
|
||||
export function deleteAutomation(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/automation/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new blueprint
|
||||
*/
|
||||
export async function addBlueprint(blueprint: AutomationBlueprintDTO): Promise<AutomationBlueprint> {
|
||||
const res = await axios.post(`${automationsPath}/blueprint`, blueprint);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update a blueprint
|
||||
*/
|
||||
export async function editBlueprint(id: string, blueprint: AutomationBlueprint): Promise<AutomationBlueprint> {
|
||||
const res = await axios.put(`${automationsPath}/blueprint/${id}`, blueprint);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a blueprint
|
||||
*/
|
||||
export function deleteBlueprint(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/blueprint/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to test automation output
|
||||
* The return is irrelevant as we care for the resolution of the promise
|
||||
|
||||
@@ -4,6 +4,6 @@ export const automationPlaceholderSettings: AutomationSettings = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
@@ -1,136 +1,457 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Select } from '@chakra-ui/react';
|
||||
import { AutomationDTO, NormalisedAutomationBlueprint, TimerLifeCycle } from 'ontime-types';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, OSCOutput } from 'ontime-types';
|
||||
|
||||
import { addAutomation, editAutomation } from '../../../../common/api/automation';
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './automationUtils';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
|
||||
|
||||
interface AutomationFormProps {
|
||||
blueprints: NormalisedAutomationBlueprint;
|
||||
initialId?: string;
|
||||
initialTitle?: string;
|
||||
initialBlueprint?: string;
|
||||
initialTrigger?: TimerLifeCycle;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
automation: Automation | AutomationDTO;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationForm(props: AutomationFormProps) {
|
||||
const { blueprints, initialId, initialTitle, initialBlueprint, initialTrigger, onCancel, postSubmit } = props;
|
||||
const { automation, onClose } = props;
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<AutomationDTO>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
title: initialTitle,
|
||||
trigger: initialTrigger,
|
||||
blueprintId: initialBlueprint,
|
||||
title: automation?.title ?? '',
|
||||
filterRule: automation?.filterRule ?? 'all',
|
||||
filters: automation?.filters ?? [],
|
||||
outputs: automation?.outputs ?? [],
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: fieldFilters,
|
||||
append: appendFilter,
|
||||
remove: removeFilter,
|
||||
} = useFieldArray({
|
||||
name: 'filters',
|
||||
control,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: fieldOutputs,
|
||||
append: appendOutput,
|
||||
remove: removeOutput,
|
||||
} = useFieldArray({
|
||||
name: 'outputs',
|
||||
control,
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount
|
||||
}, []);
|
||||
}, [setFocus]);
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
// if we were passed an ID we are editing a blueprint
|
||||
if (initialId) {
|
||||
try {
|
||||
await editAutomation(initialId, { id: initialId, ...values });
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save changes to automation ${maybeAxiosError(error)}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
|
||||
// otherwise we are creating a new automation
|
||||
const handleAddNewOSCOutput = () => {
|
||||
// @ts-expect-error -- we dont want to pass a port to the new object
|
||||
appendOutput({ type: 'osc', targetIP: '', targetPort: undefined, address: '', args: '' });
|
||||
};
|
||||
|
||||
const handleAddNewHTTPOutput = () => {
|
||||
appendOutput({ type: 'http', url: '' });
|
||||
};
|
||||
|
||||
const handleTestOSCOutput = async (index: number) => {
|
||||
try {
|
||||
await addAutomation(values);
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save automation ${maybeAxiosError(error)}` });
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'osc',
|
||||
targetIP: values.targetIP,
|
||||
targetPort: values.targetPort,
|
||||
address: values.address,
|
||||
args: values.args,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
}
|
||||
};
|
||||
|
||||
const blueprintSelect = Object.keys(blueprints).map((blueprint) => {
|
||||
return {
|
||||
value: blueprint,
|
||||
label: blueprints[blueprint].title,
|
||||
};
|
||||
});
|
||||
const handleTestHTTPOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as HTTPOutput;
|
||||
if (!values.url) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'http',
|
||||
url: values.url,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = isDirty && isValid;
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
} else {
|
||||
await handleCreate(values);
|
||||
}
|
||||
refetch();
|
||||
|
||||
async function handleEdit(id: string, values: Automation) {
|
||||
try {
|
||||
await editAutomation(id, values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(values: AutomationDTO) {
|
||||
try {
|
||||
await addAutomation(values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='automation-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
className={style.outerColumn}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.SubHeader>{initialId ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
defaultValue={initialTitle}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Trigger
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
defaultValue={initialTrigger}
|
||||
{...register('trigger', { required: { value: true, message: 'Required field' } })}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
|
||||
<div className={style.innerSection}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='Load preset'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerSection}>
|
||||
<h3>Filters</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<Controller
|
||||
name='filterRule'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioGroup {...field} size='sm' className={style.matchRadio} variant='ontime'>
|
||||
<Radio value='all'>All filters pass</Radio>
|
||||
<Radio value='any'>Any filter passes</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => (
|
||||
<div key={field.id} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select
|
||||
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
placeholder='Event field'
|
||||
>
|
||||
{fieldList.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
placeholder='Operator'
|
||||
>
|
||||
<option value='equals'>equals</option>
|
||||
<option value='not_equals'>not equals</option>
|
||||
<option value='contains'>contains</option>
|
||||
<option value='greater_than'>greater than</option>
|
||||
<option value='less_than'>less than</option>
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input
|
||||
{...register(`filters.${index}.value`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='<no value>'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
color='#FA5656' // $red-500
|
||||
onClick={() => removeFilter(index)}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Blueprint title
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
defaultValue={initialBlueprint}
|
||||
{...register('blueprintId', { required: { value: true, message: 'Required field' } })}
|
||||
>
|
||||
{blueprintSelect.map((blueprint) => (
|
||||
<option key={blueprint.value} value={blueprint.value}>
|
||||
{blueprint.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.blueprintId?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
type='submit'
|
||||
rightIcon={<IoAdd />}
|
||||
onClick={handleAddNewFilter}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Automation outputs can be used to send data from Ontime to external software.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
targetIP?: { message?: string };
|
||||
targetPort?: { message?: string };
|
||||
address?: { message?: string };
|
||||
args?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Target Port
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetPort`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<Input
|
||||
{...register(`outputs.${index}.address`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='/cue/start'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Parameters
|
||||
<Input
|
||||
{...register(`outputs.${index}.args`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
url?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>HTTP</Tag>
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<Input
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// there should be no other output types
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewOSCOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
OSC
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewHTTPOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
HTTP
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
|
||||
@@ -5,13 +5,13 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AutomationSettingsForm from './AutomationSettingsForm';
|
||||
import AutomationsList from './AutomationsList';
|
||||
import BlueprintsList from './BlueprintsList';
|
||||
import TriggersList from './TriggersList';
|
||||
|
||||
export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
const { data, status } = useAutomationSettings();
|
||||
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
|
||||
const automationRef = useScrollIntoView<HTMLDivElement>('automations', location);
|
||||
const blueprintsRef = useScrollIntoView<HTMLDivElement>('blueprints', location);
|
||||
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location);
|
||||
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
@@ -27,11 +27,11 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
oscPortIn={data.oscPortIn}
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationRef}>
|
||||
<AutomationsList automations={data.automations} blueprints={data.blueprints} />
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} />
|
||||
</div>
|
||||
<div ref={blueprintsRef}>
|
||||
<BlueprintsList blueprints={data.blueprints} />
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</>
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='automation-form'
|
||||
form='automation-settings-form'
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
@@ -87,7 +87,7 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
||||
|
||||
<Panel.Section
|
||||
as='form'
|
||||
id='automation-form'
|
||||
id='automation-settings-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
>
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Button, IconButton } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { Automation, NormalisedAutomationBlueprint } from 'ontime-types';
|
||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AutomationForm from './AutomationForm';
|
||||
import AutomationsListItem from './AutomationsListItem';
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
|
||||
const automationPlaceholder: AutomationDTO = {
|
||||
title: '',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
interface AutomationsListProps {
|
||||
automations: Automation[];
|
||||
blueprints: NormalisedAutomationBlueprint;
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
export default function AutomationsList(props: AutomationsListProps) {
|
||||
const { automations, blueprints } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { automations } = props;
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteAutomation(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
@@ -33,15 +41,7 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const postSubmit = () => {
|
||||
setShowForm(false);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const duplicates = useMemo(() => checkDuplicates(automations), [automations]);
|
||||
|
||||
// there is no point letting user creating an automation if there are no blueprints
|
||||
const canAdd = Object.keys(blueprints).length > 0;
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
@@ -52,66 +52,77 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='automation-form'
|
||||
isDisabled={!canAdd}
|
||||
isLoading={false}
|
||||
onClick={() => setShowForm(true)}
|
||||
isDisabled={Boolean(automationFormData)}
|
||||
onClick={() => setAutomationFormData(automationPlaceholder)}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and blueprint which can performance issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
{showForm && (
|
||||
<AutomationForm blueprints={blueprints} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
|
||||
)}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '20%' }}>Trigger</th>
|
||||
<th style={{ width: '30%' }}>Blueprint</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showForm && automations.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create a blueprint before adding automations'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
{automations.map((automation, index) => {
|
||||
return (
|
||||
<Fragment key={automation.id}>
|
||||
<AutomationsListItem
|
||||
blueprints={blueprints}
|
||||
id={automation.id}
|
||||
title={automation.title}
|
||||
trigger={automation.trigger}
|
||||
blueprintId={automation.blueprintId}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleDelete={() => handleDelete(automation.id)}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{arrayAutomations.length === 0 && (
|
||||
<Panel.TableEmpty handleClick={() => setAutomationFormData(automationPlaceholder)} />
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
<tr>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoPencil />}
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import {
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintDTO,
|
||||
CustomFields,
|
||||
HTTPOutput,
|
||||
isHTTPOutput,
|
||||
isOSCOutput,
|
||||
OntimeEvent,
|
||||
OSCOutput,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { addBlueprint, editBlueprint, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { isBlueprint, makeFieldList } from './automationUtils';
|
||||
|
||||
import style from './BlueprintForm.module.scss';
|
||||
|
||||
interface BlueprintFormProps {
|
||||
blueprint: AutomationBlueprintDTO | AutomationBlueprint;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BlueprintForm(props: BlueprintFormProps) {
|
||||
const { blueprint, onClose } = props;
|
||||
const isEdit = isBlueprint(blueprint);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
register,
|
||||
setError,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<AutomationBlueprintDTO>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
title: blueprint?.title ?? '',
|
||||
filterRule: blueprint?.filterRule ?? 'all',
|
||||
filters: blueprint?.filters ?? [],
|
||||
outputs: blueprint?.outputs ?? [],
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: fieldFilters,
|
||||
append: appendFilter,
|
||||
remove: removeFilter,
|
||||
} = useFieldArray({
|
||||
name: 'filters',
|
||||
control,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: fieldOutputs,
|
||||
append: appendOutput,
|
||||
remove: removeOutput,
|
||||
} = useFieldArray({
|
||||
name: 'outputs',
|
||||
control,
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
|
||||
const handleAddNewOSCOutput = () => {
|
||||
// @ts-expect-error -- we dont want to pass a port to the new object
|
||||
appendOutput({ type: 'osc', targetIP: '', targetPort: undefined, address: '', args: '' });
|
||||
};
|
||||
|
||||
const handleAddNewHTTPOutput = () => {
|
||||
appendOutput({ type: 'http', url: '' });
|
||||
};
|
||||
|
||||
const handleTestOSCOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'osc',
|
||||
targetIP: values.targetIP,
|
||||
targetPort: values.targetPort,
|
||||
address: values.address,
|
||||
args: values.args,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestHTTPOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as HTTPOutput;
|
||||
if (!values.url) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'http',
|
||||
url: values.url,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationBlueprintDTO) => {
|
||||
if (isBlueprint(blueprint)) {
|
||||
await handleEdit(blueprint.id, { id: blueprint.id, ...values });
|
||||
} else {
|
||||
await handleCreate(values);
|
||||
}
|
||||
refetch();
|
||||
|
||||
async function handleEdit(id: string, values: AutomationBlueprint) {
|
||||
try {
|
||||
await editBlueprint(id, values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(values: AutomationBlueprintDTO) {
|
||||
try {
|
||||
await addBlueprint(values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='blueprint-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={style.outerColumn}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.SubHeader>{isEdit ? 'Edit blueprint' : 'Create blueprint'}</Panel.SubHeader>
|
||||
<div className={style.innerSection}>
|
||||
<h3>Blueprint options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='Load preset'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerSection}>
|
||||
<h3>Filters</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<Controller
|
||||
name='filterRule'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioGroup {...field} size='sm' className={style.matchRadio} variant='ontime'>
|
||||
<Radio value='all'>All filters pass</Radio>
|
||||
<Radio value='any'>Any filter passes</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => (
|
||||
<div key={field.id} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select
|
||||
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
placeholder='Event field'
|
||||
>
|
||||
{fieldList.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
placeholder='Operator'
|
||||
>
|
||||
<option value='equals'>equals</option>
|
||||
<option value='not_equals'>not equals</option>
|
||||
<option value='contains'>contains</option>
|
||||
<option value='greater_than'>greater than</option>
|
||||
<option value='less_than'>less than</option>
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input
|
||||
{...register(`filters.${index}.value`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='<no value>'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
color='#FA5656' // $red-500
|
||||
onClick={() => removeFilter(index)}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
type='submit'
|
||||
rightIcon={<IoAdd />}
|
||||
onClick={handleAddNewFilter}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
targetIP?: { message?: string };
|
||||
targetPort?: { message?: string };
|
||||
address?: { message?: string };
|
||||
args?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Target Port
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetPort`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<Input
|
||||
{...register(`outputs.${index}.address`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='/cue/start'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Parameters
|
||||
<Input
|
||||
{...register(`outputs.${index}.args`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
url?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>HTTP</Tag>
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<Input
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// there should be no other output types
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewOSCOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
OSC
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewHTTPOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
HTTP
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* We use this guard to find out if the form is receiving an existing blueprint or creating a DTO
|
||||
* We do this by checking whether an ID has been generated
|
||||
*/
|
||||
function isBlueprint(blueprint: AutomationBlueprintDTO | AutomationBlueprint): blueprint is AutomationBlueprint {
|
||||
return Object.hasOwn(blueprint, 'id');
|
||||
}
|
||||
|
||||
export const staticSelectProperties = [
|
||||
{ value: 'id', label: 'ID' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'cue', label: 'Cue' },
|
||||
{ value: 'countToEnd', label: 'Count to end' },
|
||||
{ value: 'isPublic', label: 'Is public' },
|
||||
{ value: 'skip', label: 'Skip' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
{ value: 'colour', label: 'Colour' },
|
||||
{ value: 'endAction', label: 'End action' },
|
||||
{ value: 'timerType', label: 'Timer type' },
|
||||
{ value: 'timeWarning', label: 'Time warning' },
|
||||
{ value: 'timeDanger', label: 'Time danger' },
|
||||
];
|
||||
|
||||
type SelectableField = {
|
||||
value: keyof OntimeEvent | string; // string for custom fields
|
||||
label: string;
|
||||
};
|
||||
|
||||
function makeFieldList(customFields: CustomFields): SelectableField[] {
|
||||
return [
|
||||
...staticSelectProperties,
|
||||
...Object.entries(customFields).map(([key, { label }]) => ({ value: key, label: `Custom: ${label}` })),
|
||||
];
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Button, IconButton } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { AutomationBlueprintDTO, NormalisedAutomationBlueprint } from 'ontime-types';
|
||||
|
||||
import { deleteBlueprint } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import BlueprintForm from './BlueprintForm';
|
||||
|
||||
const automationBlueprintPlaceholder: AutomationBlueprintDTO = {
|
||||
title: '',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
interface BlueprintsListProps {
|
||||
blueprints: NormalisedAutomationBlueprint;
|
||||
}
|
||||
|
||||
export default function BlueprintsList(props: BlueprintsListProps) {
|
||||
const { blueprints } = props;
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [blueprintFormData, setBlueprintFormData] = useState<AutomationBlueprintDTO | AutomationBlueprintDTO | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteBlueprint(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const arrayBlueprints = Object.keys(blueprints);
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage blueprints
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
type='submit'
|
||||
isDisabled={Boolean(blueprintFormData)}
|
||||
onClick={() => setBlueprintFormData(automationBlueprintPlaceholder)}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
{blueprintFormData !== null && (
|
||||
<BlueprintForm blueprint={blueprintFormData} onClose={() => setBlueprintFormData(null)} />
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{arrayBlueprints.length === 0 && (
|
||||
<Panel.TableEmpty handleClick={() => setBlueprintFormData(automationBlueprintPlaceholder)} />
|
||||
)}
|
||||
{arrayBlueprints.map((blueprintId) => {
|
||||
if (!Object.hasOwn(blueprints, blueprintId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={blueprintId}>
|
||||
<tr>
|
||||
<td>{blueprints[blueprintId].title}</td>
|
||||
<td>
|
||||
<Tag>{blueprints[blueprintId].filterRule}</Tag>
|
||||
</td>
|
||||
<td>{blueprints[blueprintId].filters.length}</td>
|
||||
<td>{blueprints[blueprintId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoPencil />}
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setBlueprintFormData(blueprints[blueprintId])}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(blueprintId)}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Select } from '@chakra-ui/react';
|
||||
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
|
||||
|
||||
import { addTrigger, editTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './automationUtils';
|
||||
|
||||
interface TriggerFormProps {
|
||||
automations: NormalisedAutomation;
|
||||
initialId?: string;
|
||||
initialTitle?: string;
|
||||
initialAutomationId?: string;
|
||||
initialTrigger?: TimerLifeCycle;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggerForm(props: TriggerFormProps) {
|
||||
const { automations, initialId, initialTitle, initialAutomationId, initialTrigger, onCancel, postSubmit } = props;
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<TriggerDTO>({
|
||||
defaultValues: {
|
||||
title: initialTitle,
|
||||
trigger: initialTrigger,
|
||||
automationId: initialAutomationId,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (values: TriggerDTO) => {
|
||||
// if we were passed an ID we are editing a Trigger
|
||||
if (initialId) {
|
||||
try {
|
||||
await editTrigger(initialId, { id: initialId, ...values });
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise we are creating a new automation
|
||||
try {
|
||||
await addTrigger(values);
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save trigger ${maybeAxiosError(error)}` });
|
||||
}
|
||||
};
|
||||
|
||||
const automationSelect = Object.keys(automations).map((automation) => {
|
||||
return {
|
||||
value: automation,
|
||||
label: automations[automation].title,
|
||||
};
|
||||
});
|
||||
|
||||
const canSubmit = isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='trigger-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
defaultValue={initialTitle}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
defaultValue={initialTrigger}
|
||||
{...register('trigger', { required: { value: true, message: 'Required field' } })}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Automation title
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
defaultValue={initialAutomationId}
|
||||
{...register('automationId', { required: { value: true, message: 'Required field' } })}
|
||||
>
|
||||
{automationSelect.map((automation) => (
|
||||
<option key={automation.value} value={automation.value}>
|
||||
{automation.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
|
||||
import { deleteTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
export default function TriggersList(props: TriggersListProps) {
|
||||
const { triggers, automations } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteTrigger(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const postSubmit = () => {
|
||||
setShowForm(false);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
|
||||
|
||||
// there is no point letting user creating a trigger if there are no automations
|
||||
const canAdd = Object.keys(automations).length > 0;
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='trigger-form'
|
||||
isDisabled={!canAdd}
|
||||
isLoading={false}
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can performance issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
{showForm && (
|
||||
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
|
||||
)}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Lifecycle trigger</th>
|
||||
<th style={{ width: '25%' }}>Automation</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create an automation before to attach triggers to'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
return (
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
id={trigger.id}
|
||||
title={trigger.title}
|
||||
trigger={trigger.trigger}
|
||||
automationId={trigger.automationId}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
+10
-11
@@ -3,27 +3,27 @@ import { IconButton } from '@chakra-ui/react';
|
||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||
import { NormalisedAutomationBlueprint, TimerLifeCycle } from 'ontime-types';
|
||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AutomationForm from './AutomationForm';
|
||||
import { cycles } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
|
||||
interface AutomationsListItemProps {
|
||||
blueprints: NormalisedAutomationBlueprint;
|
||||
interface TriggersListItemProps {
|
||||
automations: NormalisedAutomation;
|
||||
id: string;
|
||||
title: string;
|
||||
trigger: TimerLifeCycle;
|
||||
blueprintId: string;
|
||||
automationId: string;
|
||||
duplicate?: boolean;
|
||||
handleDelete: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationsListItem(props: AutomationsListItemProps) {
|
||||
const { blueprints, id, title, trigger, blueprintId, duplicate, handleDelete, postSubmit } = props;
|
||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
if (isEditing) {
|
||||
@@ -31,11 +31,11 @@ export default function AutomationsListItem(props: AutomationsListItemProps) {
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<AutomationForm
|
||||
blueprints={blueprints}
|
||||
automations={automations}
|
||||
initialId={id}
|
||||
initialTitle={title}
|
||||
initialTrigger={trigger}
|
||||
initialBlueprint={blueprintId}
|
||||
initialAutomationId={automationId}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
postSubmit={() => {
|
||||
setIsEditing(false);
|
||||
@@ -47,7 +47,6 @@ export default function AutomationsListItem(props: AutomationsListItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const blueprintTitle = blueprints?.[blueprintId]?.title;
|
||||
return (
|
||||
<tr data-warn={duplicate}>
|
||||
<Panel.InlineElements as='td' relation='inner'>
|
||||
@@ -62,7 +61,7 @@ export default function AutomationsListItem(props: AutomationsListItemProps) {
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{blueprintTitle}</Tag>
|
||||
<Tag>{automations?.[automationId]?.title}</Tag>
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
+12
-12
@@ -1,24 +1,24 @@
|
||||
import { Automation, TimerLifeCycle } from 'ontime-types';
|
||||
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { checkDuplicates } from '../automationUtils';
|
||||
|
||||
describe('checkDuplicates', () => {
|
||||
it('should return undefined if there are no duplicates', () => {
|
||||
const automations: Automation[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, blueprintId: '1' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, blueprintId: '2' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, blueprintId: '3' },
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: '3' },
|
||||
];
|
||||
expect(checkDuplicates(automations)).toBeUndefined();
|
||||
expect(checkDuplicates(triggers)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return list of titles of duplicates', () => {
|
||||
const automations: Automation[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, blueprintId: '1' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, blueprintId: '2' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onClock, blueprintId: '1' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onPause, blueprintId: '1' },
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onClock, automationId: '1' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onPause, automationId: '1' },
|
||||
];
|
||||
expect(checkDuplicates(automations)).toStrictEqual([2]);
|
||||
expect(checkDuplicates(triggers)).toStrictEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
Automation,
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintDTO,
|
||||
AutomationDTO,
|
||||
CustomFields,
|
||||
OntimeEvent,
|
||||
TimerLifeCycle,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
|
||||
type CycleLabel = {
|
||||
@@ -26,11 +26,11 @@ export const cycles: CycleLabel[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* We use this guard to find out if the form is receiving an existing blueprint or creating a DTO
|
||||
* We use this guard to find out if the form is receiving an existing automation or creating a DTO
|
||||
* We do this by checking whether an ID has been generated
|
||||
*/
|
||||
export function isBlueprint(blueprint: AutomationBlueprintDTO | AutomationBlueprint): blueprint is AutomationBlueprint {
|
||||
return Object.hasOwn(blueprint, 'id');
|
||||
export function isAutomation(automation: AutomationDTO | Automation): automation is Automation {
|
||||
return Object.hasOwn(automation, 'id');
|
||||
}
|
||||
|
||||
export const staticSelectProperties = [
|
||||
@@ -61,22 +61,22 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* We warn the user if they have created multiple links between the same blueprint and automation
|
||||
* We warn the user if they have created multiple links between the same automation and a trigger
|
||||
*/
|
||||
export function checkDuplicates(automations: Automation[]) {
|
||||
const automationMap: Record<string, string[]> = {};
|
||||
export function checkDuplicates(triggers: Trigger[]) {
|
||||
const triggersMap: Record<string, string[]> = {};
|
||||
const duplicates = [];
|
||||
|
||||
for (let i = 0; i < automations.length; i++) {
|
||||
const automation = automations[i];
|
||||
if (!Object.hasOwn(automationMap, automation.trigger)) {
|
||||
automationMap[automation.trigger] = [];
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const trigger = triggers[i];
|
||||
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
|
||||
triggersMap[trigger.trigger] = [];
|
||||
}
|
||||
|
||||
if (automationMap[automation.trigger].includes(automation.blueprintId)) {
|
||||
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
|
||||
duplicates.push(i);
|
||||
} else {
|
||||
automationMap[automation.trigger].push(automation.blueprintId);
|
||||
triggersMap[trigger.trigger].push(trigger.automationId);
|
||||
}
|
||||
}
|
||||
return duplicates.length > 0 ? duplicates : undefined;
|
||||
|
||||
@@ -51,8 +51,8 @@ const staticOptions = [
|
||||
label: 'Automation',
|
||||
secondary: [
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__triggers', label: 'Manage triggers' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
{ id: 'automation__blueprints', label: 'Manage blueprints' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { AutomationBlueprint, AutomationBlueprintDTO, AutomationDTO, TimerLifeCycle } from 'ontime-types';
|
||||
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
|
||||
|
||||
import {
|
||||
addTrigger,
|
||||
addAutomation,
|
||||
addBlueprint,
|
||||
deleteAll,
|
||||
deleteAllAutomations,
|
||||
deleteAllTriggers,
|
||||
deleteTrigger,
|
||||
deleteAutomation,
|
||||
deleteBlueprint,
|
||||
editTrigger,
|
||||
editAutomation,
|
||||
editBlueprint,
|
||||
getAutomationTriggers,
|
||||
getAutomations,
|
||||
getBlueprints,
|
||||
} from '../automation.dao.js';
|
||||
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
||||
|
||||
@@ -21,8 +21,8 @@ beforeAll(() => {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
@@ -39,117 +39,117 @@ afterAll(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('addAutomations()', () => {
|
||||
describe('addTrigger()', () => {
|
||||
beforeEach(() => {
|
||||
deleteAllAutomations();
|
||||
deleteAllTriggers();
|
||||
});
|
||||
|
||||
it('should accept a valid automation', () => {
|
||||
const testData: AutomationDTO = {
|
||||
const testData: TriggerDTO = {
|
||||
title: 'test',
|
||||
trigger: TimerLifeCycle.onLoad,
|
||||
blueprintId: 'test-blueprint-id',
|
||||
automationId: 'test-automation-id',
|
||||
};
|
||||
|
||||
const automation = addAutomation(testData);
|
||||
expect(automation).toMatchObject(testData);
|
||||
const trigger = addTrigger(testData);
|
||||
expect(trigger).toMatchObject(testData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAutomation()', () => {
|
||||
describe('editTrigger()', () => {
|
||||
beforeEach(() => {
|
||||
deleteAllAutomations();
|
||||
addAutomation({
|
||||
deleteAllTriggers();
|
||||
addTrigger({
|
||||
title: 'test-osc',
|
||||
trigger: TimerLifeCycle.onLoad,
|
||||
blueprintId: 'test-osc-blueprint',
|
||||
automationId: 'test-osc-automation',
|
||||
});
|
||||
addAutomation({
|
||||
addTrigger({
|
||||
title: 'test-http',
|
||||
trigger: TimerLifeCycle.onFinish,
|
||||
blueprintId: 'test-http-blueprint',
|
||||
automationId: 'test-http-automation',
|
||||
});
|
||||
});
|
||||
|
||||
it('should edit the contents of an automation', () => {
|
||||
const automations = getAutomations();
|
||||
const firstAutomation = automations[0];
|
||||
expect(firstAutomation).toMatchObject({ id: expect.any(String), title: 'test-osc' });
|
||||
const triggers = getAutomationTriggers();
|
||||
const fistTrigger = triggers[0];
|
||||
expect(fistTrigger).toMatchObject({ id: expect.any(String), title: 'test-osc' });
|
||||
|
||||
const editedOSC = editAutomation(firstAutomation.id, {
|
||||
const editedOSC = editTrigger(fistTrigger.id, {
|
||||
title: 'edited-title',
|
||||
trigger: TimerLifeCycle.onDanger,
|
||||
blueprintId: 'test-osc-blueprint',
|
||||
automationId: 'test-osc-automation',
|
||||
});
|
||||
|
||||
expect(editedOSC).toMatchObject({
|
||||
id: expect.any(String),
|
||||
title: 'edited-title',
|
||||
trigger: TimerLifeCycle.onDanger,
|
||||
blueprintId: 'test-osc-blueprint',
|
||||
automationId: 'test-osc-automation',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAutomation()', () => {
|
||||
describe('deleteTrigger()', () => {
|
||||
beforeEach(() => {
|
||||
deleteAllAutomations();
|
||||
addAutomation({
|
||||
deleteAllTriggers();
|
||||
addTrigger({
|
||||
title: 'test-osc',
|
||||
trigger: TimerLifeCycle.onLoad,
|
||||
blueprintId: 'test-osc-blueprint',
|
||||
automationId: 'test-osc-automation',
|
||||
});
|
||||
addAutomation({
|
||||
addTrigger({
|
||||
title: 'test-http',
|
||||
trigger: TimerLifeCycle.onFinish,
|
||||
blueprintId: 'test-http-blueprint',
|
||||
automationId: 'test-http-automation',
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove an automation from the list', () => {
|
||||
const automations = getAutomations();
|
||||
expect(automations.length).toEqual(2);
|
||||
const firstAutomation = automations[0];
|
||||
expect(firstAutomation).toMatchObject({ id: expect.any(String), title: 'test-osc' });
|
||||
const triggers = getAutomationTriggers();
|
||||
expect(triggers.length).toEqual(2);
|
||||
const fistTrigger = triggers[0];
|
||||
expect(fistTrigger).toMatchObject({ id: expect.any(String), title: 'test-osc' });
|
||||
|
||||
deleteAutomation(firstAutomation.id);
|
||||
const removed = getAutomations();
|
||||
deleteTrigger(fistTrigger.id);
|
||||
const removed = getAutomationTriggers();
|
||||
expect(removed.length).toEqual(1);
|
||||
expect(removed[0].title).not.toEqual('test-osc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addBlueprint()', () => {
|
||||
describe('addAutomation()', () => {
|
||||
beforeEach(() => {
|
||||
deleteAll();
|
||||
});
|
||||
|
||||
it('should accept a valid blueprint', () => {
|
||||
const testData: AutomationBlueprintDTO = {
|
||||
it('should accept a valid automation', () => {
|
||||
const testData: AutomationDTO = {
|
||||
title: 'test',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [makeOSCAction(), makeHTTPAction()],
|
||||
};
|
||||
|
||||
const blueprint = addBlueprint(testData);
|
||||
const blueprints = getBlueprints();
|
||||
expect(blueprints[blueprint.id]).toMatchObject(testData);
|
||||
const automation = addAutomation(testData);
|
||||
const automations = getAutomations();
|
||||
expect(automations[automation.id]).toMatchObject(testData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editBlueprint()', () => {
|
||||
// saving the ID of the added blueprint
|
||||
let firstBlueprint: AutomationBlueprint;
|
||||
describe('editAutomation()', () => {
|
||||
// saving the ID of the added automation
|
||||
let firstAutomation: Automation;
|
||||
beforeEach(() => {
|
||||
deleteAll();
|
||||
firstBlueprint = addBlueprint({
|
||||
firstAutomation = addAutomation({
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [],
|
||||
});
|
||||
addBlueprint({
|
||||
addAutomation({
|
||||
title: 'test-http',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
@@ -157,18 +157,18 @@ describe('editBlueprint()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should edit the contents of a blueprint', () => {
|
||||
const blueprints = getBlueprints();
|
||||
expect(Object.keys(blueprints).length).toEqual(2);
|
||||
expect(blueprints[firstBlueprint.id]).toMatchObject({
|
||||
id: firstBlueprint.id,
|
||||
it('should edit the contents of an automation', () => {
|
||||
const automations = getAutomations();
|
||||
expect(Object.keys(automations).length).toEqual(2);
|
||||
expect(automations[firstAutomation.id]).toMatchObject({
|
||||
id: firstAutomation.id,
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: expect.any(Array),
|
||||
outputs: expect.any(Array),
|
||||
});
|
||||
|
||||
const editedOSC = editBlueprint(firstBlueprint.id, {
|
||||
const editedOSC = editAutomation(firstAutomation.id, {
|
||||
title: 'edited-title',
|
||||
filterRule: 'any',
|
||||
filters: [],
|
||||
@@ -176,7 +176,7 @@ describe('editBlueprint()', () => {
|
||||
});
|
||||
|
||||
expect(editedOSC).toMatchObject({
|
||||
id: firstBlueprint.id,
|
||||
id: firstAutomation.id,
|
||||
title: 'edited-title',
|
||||
filterRule: 'any',
|
||||
filters: expect.any(Array),
|
||||
@@ -185,12 +185,12 @@ describe('editBlueprint()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBlueprint()', () => {
|
||||
// saving the ID of the added blueprint
|
||||
let firstBlueprint: AutomationBlueprint;
|
||||
describe('deleteAutomation()', () => {
|
||||
// saving the ID of the added automation
|
||||
let firstAutomation: Automation;
|
||||
beforeEach(() => {
|
||||
deleteAll();
|
||||
firstBlueprint = addBlueprint({
|
||||
firstAutomation = addAutomation({
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
@@ -198,35 +198,35 @@ describe('deleteBlueprint()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove a blueprint from the list', () => {
|
||||
const blueprints = getBlueprints();
|
||||
expect(Object.keys(blueprints).length).toEqual(1);
|
||||
it('should remove m automation from the list', () => {
|
||||
const automations = getAutomations();
|
||||
expect(Object.keys(automations).length).toEqual(1);
|
||||
|
||||
deleteBlueprint(Object.keys(blueprints)[0]);
|
||||
const removed = getBlueprints();
|
||||
deleteAutomation(Object.keys(automations)[0]);
|
||||
const removed = getAutomations();
|
||||
expect(Object.keys(removed).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not remove a blueprint which is in use', () => {
|
||||
const blueprints = getBlueprints();
|
||||
addAutomation({
|
||||
it('should not remove an automation which is in use', () => {
|
||||
const automations = getAutomations();
|
||||
addTrigger({
|
||||
title: 'test-automation',
|
||||
trigger: TimerLifeCycle.onLoad,
|
||||
blueprintId: firstBlueprint.id,
|
||||
automationId: firstAutomation.id,
|
||||
});
|
||||
|
||||
const blueprintKeys = Object.keys(blueprints);
|
||||
const blueprintId = blueprintKeys[0];
|
||||
expect(blueprintId).toEqual(firstBlueprint.id);
|
||||
expect(blueprintKeys.length).toEqual(1);
|
||||
expect(blueprints[blueprintId]).toMatchObject({
|
||||
id: blueprintId,
|
||||
const automationKeys = Object.keys(automations);
|
||||
const automationId = automationKeys[0];
|
||||
expect(automationId).toEqual(firstAutomation.id);
|
||||
expect(automationKeys.length).toEqual(1);
|
||||
expect(automations[automationId]).toMatchObject({
|
||||
id: automationId,
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: expect.any(Array),
|
||||
outputs: expect.any(Array),
|
||||
});
|
||||
|
||||
expect(() => deleteBlueprint(blueprintId)).toThrowError();
|
||||
expect(() => deleteAutomation(automationId)).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
|
||||
|
||||
import { deleteAllAutomations, addAutomation, addBlueprint } from '../automation.dao.js';
|
||||
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
|
||||
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||
import * as oscClient from '../clients/osc.client.js';
|
||||
import * as httpClient from '../clients/http.client.js';
|
||||
@@ -17,8 +17,8 @@ beforeAll(() => {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
@@ -43,28 +43,28 @@ describe('triggerAction()', () => {
|
||||
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
||||
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
|
||||
|
||||
deleteAllAutomations();
|
||||
const oscBlueprint = addBlueprint({
|
||||
deleteAllTriggers();
|
||||
const oscAutomation = addAutomation({
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [makeOSCAction()],
|
||||
});
|
||||
const httpBlueprint = addBlueprint({
|
||||
const httpAutomation = addAutomation({
|
||||
title: 'test-http',
|
||||
filterRule: 'any',
|
||||
filters: [],
|
||||
outputs: [makeHTTPAction()],
|
||||
});
|
||||
addAutomation({
|
||||
addTrigger({
|
||||
title: 'test-osc',
|
||||
trigger: TimerLifeCycle.onLoad,
|
||||
blueprintId: oscBlueprint.id,
|
||||
automationId: oscAutomation.id,
|
||||
});
|
||||
addAutomation({
|
||||
addTrigger({
|
||||
title: 'test-http',
|
||||
trigger: TimerLifeCycle.onFinish,
|
||||
blueprintId: httpBlueprint.id,
|
||||
automationId: httpAutomation.id,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { Automation, AutomationBlueprint, AutomationOutput, AutomationSettings, ErrorResponse } from 'ontime-types';
|
||||
import { Automation, AutomationOutput, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -18,8 +18,8 @@ export function postAutomationSettings(req: Request, res: Response<AutomationSet
|
||||
enabledAutomations: req.body.enabledAutomations,
|
||||
enabledOscIn: req.body.enabledOscIn,
|
||||
oscPortIn: req.body.oscPortIn,
|
||||
triggers: req.body.triggers ?? undefined,
|
||||
automations: req.body.automations ?? undefined,
|
||||
blueprints: req.body.blueprints ?? undefined,
|
||||
});
|
||||
if (automationSettings.enabledOscIn) {
|
||||
oscServer.init(automationSettings.oscPortIn);
|
||||
@@ -33,12 +33,12 @@ export function postAutomationSettings(req: Request, res: Response<AutomationSet
|
||||
}
|
||||
}
|
||||
|
||||
export function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
export function postTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
|
||||
try {
|
||||
const automation = automationDao.addAutomation({
|
||||
const automation = automationDao.addTrigger({
|
||||
title: req.body.title,
|
||||
trigger: req.body.trigger,
|
||||
blueprintId: req.body.blueprintId,
|
||||
automationId: req.body.automationId,
|
||||
});
|
||||
res.status(201).send(automation);
|
||||
} catch (error) {
|
||||
@@ -47,13 +47,13 @@ export function postAutomation(req: Request, res: Response<Automation | ErrorRes
|
||||
}
|
||||
}
|
||||
|
||||
export function putAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
export function putTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
|
||||
try {
|
||||
// body payload is a patch object
|
||||
const automation = automationDao.editAutomation(req.params.id, {
|
||||
const automation = automationDao.editTrigger(req.params.id, {
|
||||
title: req.body.title ?? undefined,
|
||||
trigger: req.body.trigger ?? undefined,
|
||||
blueprintId: req.body.blueprintId ?? undefined,
|
||||
automationId: req.body.automationId ?? undefined,
|
||||
});
|
||||
res.status(200).send(automation);
|
||||
} catch (error) {
|
||||
@@ -62,6 +62,46 @@ export function putAutomation(req: Request, res: Response<Automation | ErrorResp
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTrigger(req: Request, res: Response<void | ErrorResponse>) {
|
||||
try {
|
||||
automationDao.deleteTrigger(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
try {
|
||||
const newAutomation = automationDao.addAutomation({
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
});
|
||||
res.status(201).send(newAutomation);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
try {
|
||||
const newAutomation = automationDao.editAutomation(req.params.id, {
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
});
|
||||
res.status(200).send(newAutomation);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
|
||||
try {
|
||||
automationDao.deleteAutomation(req.params.id);
|
||||
@@ -72,46 +112,6 @@ export function deleteAutomation(req: Request, res: Response<void | ErrorRespons
|
||||
}
|
||||
}
|
||||
|
||||
export function postBlueprint(req: Request, res: Response<AutomationBlueprint | ErrorResponse>) {
|
||||
try {
|
||||
const newBlueprint = automationDao.addBlueprint({
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
});
|
||||
res.status(201).send(newBlueprint);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export function editBlueprint(req: Request, res: Response<AutomationBlueprint | ErrorResponse>) {
|
||||
try {
|
||||
const newBlueprint = automationDao.editBlueprint(req.params.id, {
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
});
|
||||
res.status(200).send(newBlueprint);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteBlueprint(req: Request, res: Response<void | ErrorResponse>) {
|
||||
try {
|
||||
automationDao.deleteBlueprint(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export function testOutput(req: Request, res: Response<void | ErrorResponse>) {
|
||||
try {
|
||||
const payload = req.body as AutomationOutput;
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintDTO,
|
||||
AutomationDTO,
|
||||
AutomationSettings,
|
||||
NormalisedAutomationBlueprint,
|
||||
} from 'ontime-types';
|
||||
import type { Automation, AutomationDTO, AutomationSettings, NormalisedAutomation, Trigger, TriggerDTO } from 'ontime-types';
|
||||
import { deleteAtIndex, generateId } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
@@ -24,20 +17,23 @@ export function getAutomationsEnabled(): boolean {
|
||||
return getAutomationSettings().enabledAutomations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a copy of the stored automation triggers
|
||||
*/
|
||||
export function getAutomationTriggers(): Trigger[] {
|
||||
return getAutomationSettings().triggers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a copy of the stored automations
|
||||
*/
|
||||
export function getAutomations(): Automation[] {
|
||||
export function getAutomations(): NormalisedAutomation {
|
||||
return getAutomationSettings().automations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a copy of the stored blueprints
|
||||
* Patches the automation settings object
|
||||
*/
|
||||
export function getBlueprints(): NormalisedAutomationBlueprint {
|
||||
return getAutomationSettings().blueprints;
|
||||
}
|
||||
|
||||
export function editAutomationSettings(settings: Partial<AutomationSettings>): AutomationSettings {
|
||||
saveChanges(settings);
|
||||
return getAutomationSettings();
|
||||
@@ -46,105 +42,105 @@ export function editAutomationSettings(settings: Partial<AutomationSettings>): A
|
||||
/**
|
||||
* Adds a validated automation to the store
|
||||
*/
|
||||
export function addAutomation(newAutomation: AutomationDTO): Automation {
|
||||
const automations = getAutomations();
|
||||
const id = getUniqueAutomationId(automations);
|
||||
const automation = { ...newAutomation, id };
|
||||
automations.push(automation);
|
||||
saveChanges({ automations });
|
||||
return automation;
|
||||
export function addTrigger(newTrigger: TriggerDTO): Trigger {
|
||||
const triggers = getAutomationTriggers();
|
||||
const id = getUniqueTriggerId(triggers);
|
||||
const trigger = { ...newTrigger, id };
|
||||
triggers.push(trigger);
|
||||
saveChanges({ triggers });
|
||||
return trigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches an existing automation
|
||||
* Patches an existing automation trigger
|
||||
*/
|
||||
export function editAutomation(id: string, newAutomation: AutomationDTO): Automation {
|
||||
const automations = getAutomations();
|
||||
const index = automations.findIndex((automation) => automation.id === id);
|
||||
export function editTrigger(id: string, newTrigger: TriggerDTO): Trigger {
|
||||
const triggers = getAutomationTriggers();
|
||||
const index = triggers.findIndex((trigger) => trigger.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Automation with id ${id} not found`);
|
||||
}
|
||||
|
||||
automations[index] = { ...automations[index], ...newAutomation };
|
||||
saveChanges({ automations });
|
||||
return automations[index];
|
||||
triggers[index] = { ...triggers[index], ...newTrigger };
|
||||
saveChanges({ triggers });
|
||||
return triggers[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an automation given its ID
|
||||
* Deletes an automation trigger given its ID
|
||||
*/
|
||||
export function deleteAutomation(id: string): void {
|
||||
let automations = getAutomations();
|
||||
const index = automations.findIndex((automation) => automation.id === id);
|
||||
export function deleteTrigger(id: string): void {
|
||||
let triggers = getAutomationTriggers();
|
||||
const index = triggers.findIndex((trigger) => trigger.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Automation with id ${id} not found`);
|
||||
}
|
||||
|
||||
automations = deleteAtIndex(index, automations);
|
||||
saveChanges({ automations });
|
||||
triggers = deleteAtIndex(index, triggers);
|
||||
saveChanges({ triggers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all project automations
|
||||
* Deletes all project automation triggers
|
||||
*/
|
||||
export function deleteAllAutomations(): void {
|
||||
saveChanges({ automations: [] });
|
||||
export function deleteAllTriggers(): void {
|
||||
saveChanges({ triggers: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all project automations and blueprints
|
||||
* Deletes all project automation triggers and automations
|
||||
* We do this together to avoid issues with missing references
|
||||
*/
|
||||
export function deleteAll(): void {
|
||||
saveChanges({ automations: [], blueprints: {} });
|
||||
saveChanges({ triggers: [], automations: {} });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a validated blueprint to the store
|
||||
* Adds a validated automation to the store
|
||||
*/
|
||||
export function addBlueprint(newBlueprint: Omit<AutomationBlueprint, 'id'>): AutomationBlueprint {
|
||||
const blueprints = getBlueprints();
|
||||
const id = getUniqueBlueprintId(blueprints);
|
||||
blueprints[id] = { ...newBlueprint, id };
|
||||
saveChanges({ blueprints });
|
||||
return blueprints[id];
|
||||
export function addAutomation(newAutomation: AutomationDTO): Automation {
|
||||
const automations = getAutomations();
|
||||
const id = getUniqueAutomationId(automations);
|
||||
automations[id] = { ...newAutomation, id };
|
||||
saveChanges({ automations });
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing blueprint with a new entry
|
||||
* Updates an existing automation with a new entry
|
||||
*/
|
||||
export function editBlueprint(id: string, newBlueprint: AutomationBlueprintDTO): AutomationBlueprint {
|
||||
const blueprints = getBlueprints();
|
||||
if (!Object.hasOwn(blueprints, id)) {
|
||||
throw new Error(`Blueprint with id ${id} not found`);
|
||||
export function editAutomation(id: string, newAutomation: AutomationDTO): Automation {
|
||||
const automations = getAutomations();
|
||||
if (!Object.hasOwn(automations, id)) {
|
||||
throw new Error(`Automation with id ${id} not found`);
|
||||
}
|
||||
|
||||
blueprints[id] = { ...newBlueprint, id };
|
||||
saveChanges({ blueprints });
|
||||
return blueprints[id];
|
||||
automations[id] = { ...newAutomation, id };
|
||||
saveChanges({ automations });
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a blueprint given its ID
|
||||
* Deletes a automation given its ID
|
||||
*/
|
||||
export function deleteBlueprint(id: string): void {
|
||||
const blueprints = getBlueprints();
|
||||
// ignore request if blueprint does not exist
|
||||
if (!Object.hasOwn(blueprints, id)) {
|
||||
export function deleteAutomation(id: string): void {
|
||||
const automations = getAutomations();
|
||||
// ignore request if automation does not exist
|
||||
if (!Object.hasOwn(automations, id)) {
|
||||
return;
|
||||
}
|
||||
// prevent deleting a blueprint that is in use
|
||||
const automations = getAutomations();
|
||||
for (let i = 0; i < automations.length; i++) {
|
||||
const automation = automations[i];
|
||||
if (automation.blueprintId === id) {
|
||||
throw new Error(`Unable to delete blueprint used in automation ${automation.title}`);
|
||||
// prevent deleting a automation that is in use
|
||||
const triggers = getAutomationTriggers();
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const trigger = triggers[i];
|
||||
if (trigger.automationId === id) {
|
||||
throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
|
||||
}
|
||||
}
|
||||
delete blueprints[id];
|
||||
saveChanges({ blueprints });
|
||||
delete automations[id];
|
||||
saveChanges({ automations });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,14 +157,14 @@ async function saveChanges(patch: Partial<AutomationSettings>) {
|
||||
/**
|
||||
* Returns an ID guaranteed to be unique in an array
|
||||
*/
|
||||
function getUniqueAutomationId(automations: Automation[]): string {
|
||||
function getUniqueTriggerId(triggers: Trigger[]): string {
|
||||
let id = '';
|
||||
do {
|
||||
id = generateId();
|
||||
} while (isInArray(id));
|
||||
|
||||
function isInArray(id: string): boolean {
|
||||
return automations.some((automation) => automation.id === id);
|
||||
return triggers.some((trigger) => trigger.id === id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -176,10 +172,10 @@ function getUniqueAutomationId(automations: Automation[]): string {
|
||||
/**
|
||||
* Returns an ID guaranteed to be unique in an objects keys
|
||||
*/
|
||||
function getUniqueBlueprintId(blueprints: NormalisedAutomationBlueprint): string {
|
||||
function getUniqueAutomationId(automations: NormalisedAutomation): string {
|
||||
let id = '';
|
||||
do {
|
||||
id = generateId();
|
||||
} while (Object.hasOwn(blueprints, id));
|
||||
} while (Object.hasOwn(automations, id));
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseModel, AutomationSettings, Automation, NormalisedAutomationBlueprint } from 'ontime-types';
|
||||
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import type { ErrorEmitter } from '../../utils/parser.js';
|
||||
@@ -24,8 +24,8 @@ export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitt
|
||||
enabledAutomations: dbModel.automation.enabledAutomations,
|
||||
enabledOscIn: data.osc?.enabledIn ?? dbModel.automation.enabledOscIn,
|
||||
oscPortIn: data.osc?.portIn ?? dbModel.automation.oscPortIn,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
} else {
|
||||
return { ...dbModel.automation };
|
||||
@@ -42,17 +42,17 @@ export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitt
|
||||
enabledAutomations: data.automation.enabledAutomations ?? dbModel.automation.enabledAutomations,
|
||||
enabledOscIn: data.automation.enabledOscIn ?? dbModel.automation.enabledOscIn,
|
||||
oscPortIn: data.automation.oscPortIn ?? dbModel.automation.oscPortIn,
|
||||
triggers: parseTriggers(data.automation.triggers),
|
||||
automations: parseAutomations(data.automation.automations),
|
||||
blueprints: parseBlueprints(data.automation.blueprints),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAutomations(maybeAutomations: unknown): Automation[] {
|
||||
function parseTriggers(maybeAutomations: unknown): Trigger[] {
|
||||
if (!Array.isArray(maybeAutomations)) return [];
|
||||
return maybeAutomations as Automation[];
|
||||
return maybeAutomations as Trigger[];
|
||||
}
|
||||
|
||||
function parseBlueprints(maybeBlueprint: unknown): NormalisedAutomationBlueprint {
|
||||
if (typeof maybeBlueprint !== 'object' || maybeBlueprint === null) return {};
|
||||
return maybeBlueprint as NormalisedAutomationBlueprint;
|
||||
function parseAutomations(maybeAutomation: unknown): NormalisedAutomation {
|
||||
if (typeof maybeAutomation !== 'object' || maybeAutomation === null) return {};
|
||||
return maybeAutomation as NormalisedAutomation;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import express from 'express';
|
||||
|
||||
import {
|
||||
deleteTrigger,
|
||||
deleteAutomation,
|
||||
deleteBlueprint,
|
||||
editBlueprint,
|
||||
editAutomation,
|
||||
getAutomationSettings,
|
||||
postTrigger,
|
||||
postAutomation,
|
||||
postBlueprint,
|
||||
putAutomation,
|
||||
putTrigger,
|
||||
postAutomationSettings,
|
||||
testOutput,
|
||||
} from './automation.controller.js';
|
||||
import {
|
||||
paramContainsId,
|
||||
validateAutomationSettings,
|
||||
validateAutomation,
|
||||
validateAutomationPatch,
|
||||
validateAutomationSettings,
|
||||
validateBlueprint,
|
||||
validateBlueprintPatch,
|
||||
validateTestPayload,
|
||||
validateTrigger,
|
||||
validateTriggerPatch,
|
||||
} from './automation.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
@@ -26,12 +26,12 @@ export const router = express.Router();
|
||||
router.get('/', getAutomationSettings);
|
||||
router.post('/', validateAutomationSettings, postAutomationSettings);
|
||||
|
||||
router.post('/trigger', validateTrigger, postTrigger);
|
||||
router.put('/trigger/:id', validateTriggerPatch, putTrigger);
|
||||
router.delete('/trigger/:id', paramContainsId, deleteTrigger);
|
||||
|
||||
router.post('/automation', validateAutomation, postAutomation);
|
||||
router.put('/automation/:id', validateAutomationPatch, putAutomation);
|
||||
router.put('/automation/:id', validateAutomationPatch, editAutomation);
|
||||
router.delete('/automation/:id', paramContainsId, deleteAutomation);
|
||||
|
||||
router.post('/blueprint', validateBlueprint, postBlueprint);
|
||||
router.put('/blueprint/:id', validateBlueprintPatch, editBlueprint);
|
||||
router.delete('/blueprint/:id', paramContainsId, deleteBlueprint);
|
||||
|
||||
router.post('/test', validateTestPayload, testOutput);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isOntimeCloud } from '../../externals.js';
|
||||
|
||||
import { emitOSC } from './clients/osc.client.js';
|
||||
import { emitHTTP } from './clients/http.client.js';
|
||||
import { getAutomations, getAutomationsEnabled, getBlueprints } from './automation.dao.js';
|
||||
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
|
||||
|
||||
/**
|
||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||
@@ -23,25 +23,25 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const automations = getAutomations();
|
||||
const triggerAutomations = automations.filter((automation) => automation.trigger === event);
|
||||
const triggers = getAutomationTriggers();
|
||||
const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
|
||||
if (triggerAutomations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blueprints = getBlueprints();
|
||||
if (Object.keys(blueprints).length === 0) {
|
||||
const automations = getAutomations();
|
||||
if (Object.keys(automations).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerAutomations.forEach((automation) => {
|
||||
const blueprint = blueprints[automation.blueprintId];
|
||||
if (!blueprint) {
|
||||
triggerAutomations.forEach((trigger) => {
|
||||
const automation = automations[trigger.automationId];
|
||||
if (!automation) {
|
||||
return;
|
||||
}
|
||||
const shouldSend = testConditions(blueprint.filters, blueprint.filterRule, state);
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
|
||||
if (shouldSend) {
|
||||
send(blueprint.outputs, state);
|
||||
send(automation.outputs, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
AutomationBlueprint,
|
||||
Automation,
|
||||
AutomationFilter,
|
||||
AutomationOutput,
|
||||
HTTPOutput,
|
||||
@@ -28,11 +28,36 @@ export const validateAutomationSettings = [
|
||||
body('enabledAutomations').exists().isBoolean(),
|
||||
body('enabledOscIn').exists().isBoolean(),
|
||||
body('oscPortIn').exists().isPort(),
|
||||
body('automations').optional().isArray(),
|
||||
body('automations.*.title').optional().isString().trim(),
|
||||
body('automations.*.trigger').optional().isIn(timerLifecycleValues),
|
||||
body('automations.*.blueprintId').optional().isString().trim(),
|
||||
body('blueprints').optional().custom(parseBluePrint),
|
||||
body('triggers').optional().isArray(),
|
||||
body('triggers.*.title').optional().isString().trim(),
|
||||
body('triggers.*.trigger').optional().isIn(timerLifecycleValues),
|
||||
body('triggers.*.automationId').optional().isString().trim(),
|
||||
body('automations').optional().custom(parseAutomation),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validateTrigger = [
|
||||
body('title').exists().isString().trim(),
|
||||
body('trigger').exists().isIn(timerLifecycleValues),
|
||||
body('automationId').exists().isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validateTriggerPatch = [
|
||||
param('id').exists(),
|
||||
body('title').optional().isString().trim(),
|
||||
body('trigger').optional().isIn(timerLifecycleValues),
|
||||
body('automationId').optional().isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -42,9 +67,7 @@ export const validateAutomationSettings = [
|
||||
];
|
||||
|
||||
export const validateAutomation = [
|
||||
body('title').exists().isString().trim(),
|
||||
body('trigger').exists().isIn(timerLifecycleValues),
|
||||
body('blueprintId').exists().isString().trim(),
|
||||
body().custom(parseAutomation),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -55,30 +78,7 @@ export const validateAutomation = [
|
||||
|
||||
export const validateAutomationPatch = [
|
||||
param('id').exists(),
|
||||
body('title').optional().isString().trim(),
|
||||
body('trigger').optional().isIn(timerLifecycleValues),
|
||||
body('blueprintId').optional().isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validateBlueprint = [
|
||||
body().custom(parseBluePrint),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validateBlueprintPatch = [
|
||||
param('id').exists(),
|
||||
body().custom(parseBluePrint),
|
||||
body().custom(parseAutomation),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -88,17 +88,17 @@ export const validateBlueprintPatch = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Parses and validates a use given blueprint
|
||||
* Parses and validates a use given automation
|
||||
*/
|
||||
export function parseBluePrint(maybeBlueprint: unknown): AutomationBlueprint {
|
||||
assert.isObject(maybeBlueprint);
|
||||
assert.hasKeys(maybeBlueprint, ['title', 'filterRule', 'filters', 'outputs']);
|
||||
export function parseAutomation(maybeAutomation: unknown): Automation {
|
||||
assert.isObject(maybeAutomation);
|
||||
assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']);
|
||||
|
||||
const { title, filterRule, filters, outputs } = maybeBlueprint;
|
||||
const { title, filterRule, filters, outputs } = maybeAutomation;
|
||||
assert.isString(title);
|
||||
assert.isString(filterRule);
|
||||
if (!isFilterRule(filterRule)) {
|
||||
throw new Error(`Invalid blueprint: unknown filter rule ${filterRule}`);
|
||||
throw new Error(`Invalid automation: unknown filter rule ${filterRule}`);
|
||||
}
|
||||
assert.isArray(filters);
|
||||
validateFilters(filters);
|
||||
@@ -106,7 +106,7 @@ export function parseBluePrint(maybeBlueprint: unknown): AutomationBlueprint {
|
||||
assert.isArray(outputs);
|
||||
validateOutput(outputs);
|
||||
|
||||
return maybeBlueprint as AutomationBlueprint;
|
||||
return maybeAutomation as Automation;
|
||||
}
|
||||
|
||||
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
|
||||
@@ -119,7 +119,7 @@ function validateFilters(filters: Array<unknown>): filters is AutomationFilter[]
|
||||
assert.isString(operator);
|
||||
assert.isString(value);
|
||||
if (!isFilterOperator(operator)) {
|
||||
throw new Error(`Invalid blueprint: unknown filter operator ${operator}`);
|
||||
throw new Error(`Invalid automation: unknown filter operator ${operator}`);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('safeMerge', () => {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8000,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
},
|
||||
} as DatabaseModel;
|
||||
|
||||
@@ -127,8 +127,8 @@ describe('safeMerge', () => {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8000,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
},
|
||||
} as DatabaseModel;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const dbModel: DatabaseModel = {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -454,7 +454,7 @@ export const demoDb: DatabaseModel = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
triggers: [],
|
||||
automations: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -440,8 +440,8 @@
|
||||
"enabledAutomations": false,
|
||||
"enabledOscIn": true,
|
||||
"oscPortIn": 8888,
|
||||
"automations": [],
|
||||
"blueprints": {}
|
||||
"triggers": [],
|
||||
"automations": {}
|
||||
},
|
||||
"customFields": {
|
||||
"song": {
|
||||
|
||||
Vendored
+2
-2
@@ -440,8 +440,8 @@
|
||||
"enabledAutomations": false,
|
||||
"enabledOscIn": true,
|
||||
"oscPortIn": 8888,
|
||||
"automations": [],
|
||||
"blueprints": {}
|
||||
"triggers": [],
|
||||
"automations": {}
|
||||
},
|
||||
"customFields": {
|
||||
"song": {
|
||||
|
||||
@@ -4,33 +4,33 @@ export type AutomationSettings = {
|
||||
enabledAutomations: boolean;
|
||||
enabledOscIn: boolean;
|
||||
oscPortIn: number;
|
||||
automations: Automation[];
|
||||
blueprints: NormalisedAutomationBlueprint;
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
};
|
||||
|
||||
type BlueprintId = string;
|
||||
type AutomationId = string;
|
||||
export type FilterRule = 'all' | 'any';
|
||||
|
||||
export type AutomationBlueprint = {
|
||||
id: BlueprintId;
|
||||
export type Automation = {
|
||||
id: AutomationId;
|
||||
title: string;
|
||||
filterRule: FilterRule;
|
||||
filters: AutomationFilter[];
|
||||
outputs: AutomationOutput[];
|
||||
};
|
||||
|
||||
export type AutomationBlueprintDTO = Omit<AutomationBlueprint, 'id'>;
|
||||
export type AutomationDTO = Omit<Automation, 'id'>;
|
||||
|
||||
export type NormalisedAutomationBlueprint = Record<BlueprintId, AutomationBlueprint>;
|
||||
export type NormalisedAutomation = Record<AutomationId, Automation>;
|
||||
|
||||
export type Automation = {
|
||||
export type Trigger = {
|
||||
id: string;
|
||||
title: string;
|
||||
trigger: TimerLifeCycle;
|
||||
blueprintId: BlueprintId;
|
||||
automationId: AutomationId;
|
||||
};
|
||||
|
||||
export type AutomationDTO = Omit<Automation, 'id'>;
|
||||
export type TriggerDTO = Omit<Trigger, 'id'>;
|
||||
|
||||
export type AutomationFilter = {
|
||||
field: string; // this should be a key of a OntimeEvent + custom fields
|
||||
|
||||
@@ -18,17 +18,17 @@ export { TimerType } from './definitions/TimerType.type.js';
|
||||
|
||||
// ---> Automations
|
||||
export type {
|
||||
AutomationSettings,
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintDTO,
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationFilter,
|
||||
AutomationSettings,
|
||||
AutomationOutput,
|
||||
FilterRule,
|
||||
HTTPOutput,
|
||||
NormalisedAutomationBlueprint,
|
||||
NormalisedAutomation,
|
||||
OSCOutput,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from './definitions/core/Automation.type.js';
|
||||
|
||||
// ---> Project Data
|
||||
|
||||
Reference in New Issue
Block a user