mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 07:29:08 +00:00
refactor(settings): polish settings UI
This commit is contained in:
committed by
Carlos Valente
parent
f0c284b708
commit
24be49ef66
+8
-1
@@ -1,6 +1,13 @@
|
||||
.outerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $ui-white;
|
||||
|
||||
// the shared modal body owns scrolling for this regular form modal
|
||||
min-height: 100%;
|
||||
padding-block: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
|
||||
+309
-279
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
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 { isAutomation, makeFieldList } from './automationUtils';
|
||||
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
const formId = 'automation-form';
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
@@ -185,291 +186,320 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='automation-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={style.outerColumn}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<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' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerSection}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(
|
||||
`filters.${index}.operator`,
|
||||
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
]}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{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' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='127.0.0.1'
|
||||
/>
|
||||
<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' },
|
||||
})}
|
||||
fluid
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(
|
||||
`filters.${index}.operator`,
|
||||
value as
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'contains'
|
||||
| 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
]}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</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
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{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' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='127.0.0.1'
|
||||
/>
|
||||
<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' },
|
||||
})}
|
||||
fluid
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.args`)}
|
||||
value={output.args}
|
||||
fluid
|
||||
placeholder='1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</OntimeActionForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</OntimeActionForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// there should be no other output types
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={handleAddNewOSCOutput}>
|
||||
OSC <IoAdd />
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={handleAddNewOSCOutput}>
|
||||
OSC <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddNewHTTPOutput}>
|
||||
HTTP <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddnewOntimeAction}>
|
||||
Ontime action <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={handleAddNewHTTPOutput}>
|
||||
HTTP <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddnewOntimeAction}>
|
||||
Ontime action <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,24 +19,27 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
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}
|
||||
automationState={automationState}
|
||||
oscInputState={oscInputState}
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
<div ref={settingsRef}>
|
||||
<AutomationSettingsForm
|
||||
enabledAutomations={data.enabledAutomations}
|
||||
enabledOscIn={data.enabledOscIn}
|
||||
oscPortIn={data.oscPortIn}
|
||||
automationState={automationState}
|
||||
oscInputState={oscInputState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList
|
||||
triggers={data.triggers}
|
||||
automations={data.automations}
|
||||
enabledAutomations={automationState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+122
-117
@@ -21,6 +21,7 @@ interface AutomationSettingsProps {
|
||||
oscPortIn: number;
|
||||
automationState?: boolean;
|
||||
oscInputState?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationSettingsForm({
|
||||
@@ -29,6 +30,7 @@ export default function AutomationSettingsForm({
|
||||
oscPortIn,
|
||||
automationState,
|
||||
oscInputState,
|
||||
isLoading,
|
||||
}: AutomationSettingsProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -65,126 +67,129 @@ export default function AutomationSettingsForm({
|
||||
const oscInputEnabled = watch('enabledOscIn');
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Automation settings
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
form='automation-settings-form'
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.Loader isLoading={Boolean(isLoading)} />
|
||||
<Panel.SubHeader>
|
||||
Automation settings
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
form='automation-settings-form'
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
|
||||
<Panel.Divider />
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
<Panel.Section
|
||||
as='form'
|
||||
id='automation-settings-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
>
|
||||
<Panel.Loader isLoading={false} />
|
||||
<Panel.Section
|
||||
as='form'
|
||||
id='automation-settings-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
>
|
||||
<Panel.Title>Automation</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>Enable automations</span>
|
||||
{automationState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
automationState === false
|
||||
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
|
||||
: 'Allow Ontime to send messages on lifecycle triggers'
|
||||
}
|
||||
descriptionTone={automationState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledAutomations?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={automationsEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.Title>OSC Input</Panel.Title>
|
||||
|
||||
<Panel.Title>Automation</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>Enable automations</span>
|
||||
{automationState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
automationState === false
|
||||
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
|
||||
: 'Allow Ontime to send messages on lifecycle triggers'
|
||||
}
|
||||
descriptionTone={automationState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledAutomations?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={automationsEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.Title>OSC Input</Panel.Title>
|
||||
|
||||
<Panel.ListGroup>
|
||||
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>OSC input</span>
|
||||
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
oscInputState === false
|
||||
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
|
||||
: 'Allow control of Ontime through OSC'
|
||||
}
|
||||
descriptionTone={oscInputState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledOscIn?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={oscInputEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</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'
|
||||
maxLength={5}
|
||||
style={{ textAlign: 'right', width: '5rem' }}
|
||||
type='number'
|
||||
fluid
|
||||
{...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>
|
||||
<Panel.ListGroup>
|
||||
{isOntimeCloud && (
|
||||
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
|
||||
)}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>OSC input</span>
|
||||
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
oscInputState === false
|
||||
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
|
||||
: 'Allow control of Ontime through OSC'
|
||||
}
|
||||
descriptionTone={oscInputState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledOscIn?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={oscInputEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</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'
|
||||
maxLength={5}
|
||||
style={{ textAlign: 'right', width: '5rem' }}
|
||||
type='number'
|
||||
fluid
|
||||
{...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>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
|
||||
interface AutomationsListProps {
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationsList(props: AutomationsListProps) {
|
||||
const { automations, enabledAutomations } = props;
|
||||
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -44,89 +44,94 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={Boolean(automationFormData)}
|
||||
onClick={() => setAutomationFormData(automationPlaceholder)}
|
||||
>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
<Panel.Divider />
|
||||
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||
enabled.
|
||||
</Info>
|
||||
)}
|
||||
|
||||
{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={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
|
||||
/>
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||
enabled.
|
||||
</Info>
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
|
||||
<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>
|
||||
{!isLoading && arrayAutomations.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No automations yet'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -6,30 +6,21 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles } from './automationUtils';
|
||||
|
||||
const formId = 'trigger-form';
|
||||
|
||||
interface TriggerFormProps {
|
||||
automations: NormalisedAutomation;
|
||||
initialId?: string;
|
||||
initialTitle?: string;
|
||||
initialAutomationId?: string;
|
||||
initialTrigger?: TimerLifeCycle;
|
||||
trigger: Trigger | null;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggerForm({
|
||||
automations,
|
||||
initialId,
|
||||
initialTitle,
|
||||
initialAutomationId,
|
||||
initialTrigger,
|
||||
onCancel,
|
||||
postSubmit,
|
||||
}: TriggerFormProps) {
|
||||
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -40,9 +31,9 @@ export default function TriggerForm({
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<TriggerDTO>({
|
||||
defaultValues: {
|
||||
title: initialTitle,
|
||||
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
title: trigger?.title,
|
||||
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
@@ -55,10 +46,9 @@ export default function TriggerForm({
|
||||
}, [setFocus]);
|
||||
|
||||
const onSubmit = async (values: TriggerDTO) => {
|
||||
// if we were passed an ID we are editing a Trigger
|
||||
if (initialId) {
|
||||
if (trigger) {
|
||||
try {
|
||||
await editTrigger(initialId, { id: initialId, ...values });
|
||||
await editTrigger(trigger.id, { id: trigger.id, ...values });
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
||||
@@ -85,56 +75,63 @@ export default function TriggerForm({
|
||||
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' } })}
|
||||
fluid
|
||||
defaultValue={initialTitle}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
value={watch('trigger')}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
||||
}}
|
||||
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
||||
aria-label='Lifecycle trigger'
|
||||
/>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Automation title
|
||||
<Select
|
||||
value={watch('automationId')}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('automationId', value, { shouldDirty: true });
|
||||
}}
|
||||
options={automationSelect}
|
||||
aria-label='Automation title'
|
||||
/>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title={trigger ? 'Edit trigger' : 'Create trigger'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
defaultValue={trigger?.title}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
value={watch('trigger')}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
||||
}}
|
||||
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
||||
aria-label='Lifecycle trigger'
|
||||
/>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Automation title
|
||||
<Select
|
||||
value={watch('automationId')}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('automationId', value, { shouldDirty: true });
|
||||
}}
|
||||
options={automationSelect}
|
||||
aria-label='Automation title'
|
||||
/>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,23 +9,33 @@ import Info from '../../../../common/components/info/Info';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
import TriggerForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
trigger?: Trigger;
|
||||
};
|
||||
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function TriggersList(props: TriggersListProps) {
|
||||
const { triggers, automations, enabledAutomations } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
|
||||
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteTrigger(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
@@ -35,7 +45,7 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
};
|
||||
|
||||
const postSubmit = () => {
|
||||
setShowForm(false);
|
||||
closeForm();
|
||||
refetch();
|
||||
};
|
||||
|
||||
@@ -45,70 +55,86 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
const canAdd = Object.keys(automations).length > 0;
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{formState.isOpen && (
|
||||
<TriggerForm
|
||||
automations={automations}
|
||||
trigger={formState.trigger ?? null}
|
||||
onCancel={closeForm}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
)}
|
||||
{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 a trigger to run an automation'
|
||||
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>
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button disabled={!canAdd} onClick={openNewForm}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
</Info>
|
||||
)}
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can cause performance
|
||||
issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
<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>
|
||||
{!isLoading && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No triggers yet'
|
||||
description={
|
||||
canAdd
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||
: 'Create an automation first, then add a trigger to decide when it should run.'
|
||||
}
|
||||
action={
|
||||
canAdd && (
|
||||
<Button variant='primary' onClick={openNewForm}>
|
||||
Create trigger <IoAdd />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
return (
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
trigger={trigger}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleEdit={() => openEditForm(trigger)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,21 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
|
||||
interface TriggersListItemProps {
|
||||
automations: NormalisedAutomation;
|
||||
id: string;
|
||||
title: string;
|
||||
trigger: TimerLifeCycle;
|
||||
automationId: string;
|
||||
trigger: Trigger;
|
||||
duplicate?: boolean;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<AutomationForm
|
||||
automations={automations}
|
||||
initialId={id}
|
||||
initialTitle={title}
|
||||
initialTrigger={trigger}
|
||||
initialAutomationId={automationId}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
postSubmit={() => {
|
||||
setIsEditing(false);
|
||||
postSubmit();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
||||
|
||||
return (
|
||||
<tr data-warn={duplicate}>
|
||||
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
color='#FFBC56' // $orange-500
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
{trigger.title}
|
||||
</Panel.InlineElements>
|
||||
<td>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{automations?.[automationId]?.title}</Tag>
|
||||
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
|
||||
|
||||
Reference in New Issue
Block a user