feat: automation UI

merge with automation service
This commit is contained in:
Carlos Valente
2024-11-24 14:33:54 +01:00
committed by Carlos Valente
parent 05e61e7bf8
commit 8b84963e17
19 changed files with 1473 additions and 26 deletions
+85
View File
@@ -0,0 +1,85 @@
import axios from 'axios';
import type {
Automation,
AutomationBlueprint,
AutomationBlueprintDTO,
AutomationDTO,
AutomationOutput,
AutomationSettings,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update an automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete an 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
*/
export async function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(automationsPath, output);
}
+1 -2
View File
@@ -4,9 +4,8 @@ import { serverURL } from '../../externals';
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
@@ -3,6 +3,7 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
@@ -31,6 +32,7 @@ export default function AppSettings() {
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
{panel === 'network' && <NetworkLogPanel location={location} />}
@@ -10,7 +10,6 @@
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
@@ -18,4 +17,5 @@
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
}
@@ -26,13 +26,13 @@ $inner-padding: 1rem;
color: $gray-300;
}
.section, .indent {
.section,
.indent {
font-size: calc(1rem - 1px);
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
}
.section {
@@ -111,6 +111,12 @@ $inner-padding: 1rem;
tr:nth-child(even) {
background-color: $white-1;
}
// allow highlighting table elements
tr[data-warn='true'],
td[data-warn='true'] {
background-color: $orange-1300;
}
}
.listGroup {
@@ -177,7 +183,7 @@ $inner-padding: 1rem;
color: $muted-gray;
td {
padding: 2rem;
padding-block: 1rem;
}
button {
@@ -185,23 +191,21 @@ $inner-padding: 1rem;
}
}
.inlineSiblings {
.inlineElements {
display: flex;
align-items: center;
gap: 1rem;
}
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
&.inner {
gap: 0.5rem;
}
button {
margin-top: 1rem;
&.component {
gap: 1rem;
}
&.start {
justify-content: flex-start;
}
&.end {
justify-content: flex-end;
}
}
@@ -1,4 +1,4 @@
import { HTMLAttributes, ReactNode } from 'react';
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -55,20 +55,19 @@ export function Card({ children, className, ...props }: { children: ReactNode }
}
export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
return (
<div className={style.pad}>
<table className={classes}>{children}</table>
<table className={cx([style.table, className])}>{children}</table>
</div>
);
}
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<tr className={style.empty}>
<td colSpan={99}>
<div>No data yet</div>
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
<div>{label ?? 'No data yet'}</div>
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
New
</Button>
</td>
@@ -124,3 +123,22 @@ export function Loader({ isLoading }: { isLoading: boolean }) {
</div>
);
}
type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end';
className?: string;
};
export function InlineElements<C extends AllowedInlineTags = 'div'>({
children,
as,
relation = 'component',
align = 'start',
className,
}: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div';
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>;
}
@@ -0,0 +1,139 @@
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 { addAutomation, editAutomation } 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 AutomationFormProps {
blueprints: NormalisedAutomationBlueprint;
initialId?: string;
initialTitle?: string;
initialBlueprint?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void;
postSubmit: () => void;
}
export default function AutomationForm(props: AutomationFormProps) {
const { blueprints, initialId, initialTitle, initialBlueprint, initialTrigger, onCancel, postSubmit } = props;
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<AutomationDTO>({
defaultValues: {
title: initialTitle,
trigger: initialTrigger,
blueprintId: initialBlueprint,
},
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: 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;
}
// otherwise we are creating a new automation
try {
await addAutomation(values);
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save automation ${maybeAxiosError(error)}` });
}
};
const blueprintSelect = Object.keys(blueprints).map((blueprint) => {
return {
value: blueprint,
label: blueprints[blueprint].title,
};
});
const canSubmit = isDirty && isValid;
return (
<Panel.Indent
as='form'
name='automation-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<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>
))}
</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>
<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,39 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationSettingsForm from './AutomationSettingsForm';
import AutomationsList from './AutomationsList';
import BlueprintsList from './BlueprintsList';
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 isLoading = status === 'pending';
return (
<>
<Panel.Header>Automation</Panel.Header>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
/>
</div>
<div ref={automationRef}>
<AutomationsList automations={data.automations} blueprints={data.blueprints} />
</div>
<div ref={blueprintsRef}>
<BlueprintsList blueprints={data.blueprints} />
</div>
</Panel.Section>
</>
);
}
@@ -0,0 +1,161 @@
import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
interface AutomationSettingsProps {
enabledAutomations: boolean;
enabledOscIn: boolean;
oscPortIn: number;
}
export default function AutomationSettingsForm(props: AutomationSettingsProps) {
const { enabledAutomations, enabledOscIn, oscPortIn } = props;
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationSettingsProps>({
mode: 'onChange',
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
values: { enabledAutomations, enabledOscIn, oscPortIn },
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (formData: AutomationSettingsProps) => {
try {
await editAutomationSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
}
};
const onReset = () => {
reset({ enabledAutomations, enabledOscIn, oscPortIn });
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Card>
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='automation-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Control Ontime and share its data with external systems in your workflow. <br />
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section
as='form'
id='automation-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Loader isLoading={false} />
<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Enable automations'
description='Allow Ontime to send messages on lifecycle triggers'
error={errors.enabledAutomations?.message}
/>
<Controller
control={control}
name='enabledAutomations'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='OSC input'
description='Allow control of Ontime through OSC'
error={errors.enabledOscIn?.message}
/>
<Controller
control={control}
name='enabledOscIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
);
}
@@ -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 { Automation, NormalisedAutomationBlueprint } from 'ontime-types';
import { deleteAutomation } 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 AutomationForm from './AutomationForm';
import AutomationsListItem from './AutomationsListItem';
import { checkDuplicates } from './automationUtils';
interface AutomationsListProps {
automations: Automation[];
blueprints: NormalisedAutomationBlueprint;
}
export default function AutomationsList(props: AutomationsListProps) {
const { automations, blueprints } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
await deleteAutomation(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
}
};
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;
return (
<Panel.Card>
<Panel.SubHeader>
Manage automations
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
form='automation-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 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>
</Panel.Card>
);
}
@@ -0,0 +1,87 @@
import { useState } from 'react';
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 Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
import { cycles } from './automationUtils';
interface AutomationsListItemProps {
blueprints: NormalisedAutomationBlueprint;
id: string;
title: string;
trigger: TimerLifeCycle;
blueprintId: string;
duplicate?: boolean;
handleDelete: () => void;
postSubmit: () => void;
}
export default function AutomationsListItem(props: AutomationsListItemProps) {
const { blueprints, id, title, trigger, blueprintId, duplicate, handleDelete, postSubmit } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
blueprints={blueprints}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialBlueprint={blueprintId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
const blueprintTitle = blueprints?.[blueprintId]?.title;
return (
<tr data-warn={duplicate}>
<Panel.InlineElements as='td' relation='inner'>
{duplicate && (
<IoWarningOutline
color='#FFBC56' // $orange-500
/>
)}
{title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td>
<td>
<Tag>{blueprintTitle}</Tag>
</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={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={handleDelete}
/>
</Panel.InlineElements>
</tr>
);
}
@@ -0,0 +1,82 @@
.outerColumn {
gap: 2rem;
margin-bottom: 2rem;
h3 {
font-size: 1rem;
}
}
.innerColumn {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.matchRadio {
display: flex;
gap: 1rem;
}
.ruleSection {
display: flex;
flex-direction: column;
gap: 1rem;
}
.titleSection,
.filterSection,
.oscSection,
.httpSection,
.companionSection {
display: grid;
grid-gap: 0.5rem;
button {
align-self: end;
}
}
.titleSection,
.ruleSection,
.filterSection,
.oscSection,
.httpSection,
.companionSection {
label {
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.titleSection {
grid-template-columns: 1fr;
}
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actions {
justify-content: flex-end;
}
.actionButtons {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.outputCard {
border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem;
}
@@ -0,0 +1,446 @@
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,
HTTPOutput,
isHTTPOutput,
isOSCOutput,
OSCOutput,
} from 'ontime-types';
import { addBlueprint, editBlueprint } 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,
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' isDisabled={!canTest} onClick={handleTestOSCOutput}>
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;
const canTest = output.url;
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' isDisabled={!canTest} onClick={handleTestHTTPOutput}>
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>
);
}
@@ -0,0 +1,130 @@
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,24 @@
import { Automation, TimerLifeCycle } 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' },
];
expect(checkDuplicates(automations)).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' },
];
expect(checkDuplicates(automations)).toStrictEqual([2]);
});
});
@@ -0,0 +1,83 @@
import {
Automation,
AutomationBlueprint,
AutomationBlueprintDTO,
CustomFields,
OntimeEvent,
TimerLifeCycle,
} from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
{ id: 7, label: 'On Finish', value: 'onFinish' },
{ id: 8, label: 'On Warning', value: 'onWarning' },
{ id: 9, label: 'On Danger', value: 'onDanger' },
];
/**
* 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
*/
export 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;
};
export function makeFieldList(customFields: CustomFields): SelectableField[] {
return [
...staticSelectProperties,
...Object.entries(customFields).map(([key, { label }]) => ({ value: key, label: `Custom: ${label}` })),
];
}
/**
* We warn the user if they have created multiple links between the same blueprint and automation
*/
export function checkDuplicates(automations: Automation[]) {
const automationMap: 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] = [];
}
if (automationMap[automation.trigger].includes(automation.blueprintId)) {
duplicates.push(i);
} else {
automationMap[automation.trigger].push(automation.blueprintId);
}
}
return duplicates.length > 0 ? duplicates : undefined;
}
@@ -54,6 +54,15 @@ const staticOptions = [
{ id: 'integrations__http', label: 'HTTP settings' },
],
},
{
id: 'automation',
label: 'Automation',
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__blueprints', label: 'Manage blueprints' },
],
},
{
id: 'network',
label: 'Network',
+21
View File
@@ -1,3 +1,24 @@
export const ontimeRadio = {
control: {
borderColor: '#262626', // $gray-1200
backgroundColor: '#262626', // $gray-1200
_checked: {
borderColor: '#262626', // $gray-1200
color: '#f6f6f6', // $ui-white
backgroundColor: '#f6f6f6', // $ui-white
},
},
label: {
color: '#9d9d9d', // $gray-500, same as placeholder value
_checked: {
color: '#f6f6f6', // $gray-200
},
_hover: {
color: '#e2e2e2', // $gray-200
},
},
};
export const ontimeBlockRadio = {
control: {
borderColor: '#262626', // $gray-1200
+2 -1
View File
@@ -14,7 +14,7 @@ import { ontimeDrawer } from './ontimeDrawer';
import { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal } from './ontimeModal';
import { ontimeBlockRadio } from './ontimeRadio';
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { ontimeSwitch } from './ontimeSwitch';
import { ontimeTab } from './ontimeTab';
@@ -109,6 +109,7 @@ const theme = extendTheme({
},
Radio: {
variants: {
ontime: { ...ontimeRadio },
'ontime-block': { ...ontimeBlockRadio },
},
},