mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
refactor: improve automation UX
This commit is contained in:
+16
-58
@@ -4,18 +4,6 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section,
|
|
||||||
.formSection {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
color: $label-gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.triggerList {
|
.triggerList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -25,25 +13,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerForm {
|
.triggerHeader {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formFields {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8rem 1fr;
|
grid-template-columns: 8rem 1fr 2rem;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
align-items: end;
|
padding: 0.375rem 0.75rem;
|
||||||
}
|
font-size: $aux-text-size;
|
||||||
|
color: $label-gray;
|
||||||
.formActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-height: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trigger {
|
.trigger {
|
||||||
@@ -51,40 +27,22 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8rem 1fr 2rem;
|
grid-template-columns: 8rem 1fr 2rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
|
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
border-top: 1px solid $white-10;
|
border-top: 1px solid $white-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-duplicate] {
|
||||||
|
> button:not(:last-child) {
|
||||||
|
border-color: $orange-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerMeta {
|
.duplicateMessage {
|
||||||
min-width: 0;
|
padding-left: 0.75rem;
|
||||||
}
|
|
||||||
|
|
||||||
.metaLabel {
|
|
||||||
color: $label-gray;
|
|
||||||
font-size: $aux-text-size;
|
font-size: $aux-text-size;
|
||||||
line-height: 1.2;
|
color: $orange-500;
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.automationTitle {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validationError,
|
|
||||||
.validationSuccess {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validationError {
|
|
||||||
color: $red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validationSuccess {
|
|
||||||
color: $green-500;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
|
import { Trigger } from 'ontime-types';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||||
@@ -21,174 +19,102 @@ interface EventEditorTriggersProps {
|
|||||||
|
|
||||||
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
|
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
|
||||||
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
||||||
|
const { updateEntry } = useEntryActionsContext();
|
||||||
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
||||||
const showTriggers = triggers.length > 0;
|
|
||||||
|
const allAutomationOptions = Object.values(automationSettings.automations).map(({ id, title }) => ({
|
||||||
|
value: id,
|
||||||
|
label: title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
|
||||||
|
|
||||||
|
const duplicateIds = new Set<string>();
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const trigger of triggers) {
|
||||||
|
const key = `${trigger.trigger}:${trigger.automationId}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
duplicateIds.add(trigger.id);
|
||||||
|
} else {
|
||||||
|
seen.set(key, trigger.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (allAutomationOptions.length === 0) return;
|
||||||
|
updateEntry({
|
||||||
|
id: eventId,
|
||||||
|
triggers: [
|
||||||
|
...triggers,
|
||||||
|
{ id: generateId(), title: '', trigger: eventTriggerOptions[0], automationId: allAutomationOptions[0].value },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (triggerId: string) => {
|
||||||
|
updateEntry({ id: eventId, triggers: triggers.filter((t) => t.id !== triggerId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (triggerId: string, field: 'trigger' | 'automationId', value: string) => {
|
||||||
|
const newTriggers = triggers.map((t) => (t.id !== triggerId ? t : { ...t, [field]: value }));
|
||||||
|
updateEntry({ id: eventId, triggers: newTriggers });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.triggers}>
|
<div className={style.triggers}>
|
||||||
{automationsEnabled === false && (
|
{automationsEnabled === false && (
|
||||||
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
||||||
)}
|
)}
|
||||||
{showTriggers && (
|
{triggers.length > 0 && (
|
||||||
<div className={style.section}>
|
<div className={style.triggerList}>
|
||||||
<div className={style.sectionTitle}>Applied automations</div>
|
<div className={style.triggerHeader}>
|
||||||
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
<span>Lifecycle</span>
|
||||||
</div>
|
<span>Automation</span>
|
||||||
)}
|
</div>
|
||||||
<Editor.Panel className={style.formSection}>
|
{triggers.map((trigger) => {
|
||||||
<div className={style.sectionTitle}>Add automation</div>
|
const isDuplicate = duplicateIds.has(trigger.id);
|
||||||
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
const lifecycleOptions = isDuplicate
|
||||||
</Editor.Panel>
|
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
|
||||||
</div>
|
: triggerOptions;
|
||||||
);
|
const automationOptions = isDuplicate
|
||||||
}
|
? allAutomationOptions.map((opt) =>
|
||||||
|
opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt,
|
||||||
interface EventTriggerFormProps {
|
)
|
||||||
eventId: string;
|
: allAutomationOptions;
|
||||||
triggers?: Trigger[];
|
|
||||||
automations: NormalisedAutomation;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
|
|
||||||
const { updateEntry } = useEntryActionsContext();
|
|
||||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
|
||||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
|
||||||
|
|
||||||
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
|
||||||
const newTriggers = triggers ?? new Array<Trigger>();
|
|
||||||
const id = generateId();
|
|
||||||
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
|
||||||
updateEntry({ id: eventId, triggers: newTriggers });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
|
||||||
if (automationId === undefined) {
|
|
||||||
return 'Select an automation';
|
|
||||||
}
|
|
||||||
if (!Object.keys(automations).includes(automationId)) {
|
|
||||||
return 'This automation does not exist';
|
|
||||||
}
|
|
||||||
if (triggers === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
|
||||||
? 'Automation can only be used once'
|
|
||||||
: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationError = getValidationError(cycleValue, automationId);
|
|
||||||
const validationLabel = validationError ?? 'Ready to add automation';
|
|
||||||
|
|
||||||
const triggerOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: null, label: 'Select lifecycle' },
|
|
||||||
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
|
|
||||||
],
|
|
||||||
[], // eventTriggerOptions is a constant, no need for dependency
|
|
||||||
);
|
|
||||||
|
|
||||||
const automationOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: null, label: 'Select Automation' },
|
|
||||||
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
|
|
||||||
],
|
|
||||||
[automations], // This needs to be a dependency as it can change
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.triggerForm}>
|
|
||||||
<div className={style.formFields}>
|
|
||||||
<div>
|
|
||||||
<Editor.Label>Lifecycle</Editor.Label>
|
|
||||||
<Select
|
|
||||||
value={cycleValue}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value !== null) setCycleValue(value);
|
|
||||||
}}
|
|
||||||
options={triggerOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Editor.Label>Automation</Editor.Label>
|
|
||||||
<Select
|
|
||||||
value={automationId ?? null}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value !== null) setAutomationId(value);
|
|
||||||
}}
|
|
||||||
options={automationOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formActions}>
|
|
||||||
<div className={validationError ? style.validationError : style.validationSuccess}>
|
|
||||||
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
|
|
||||||
<span>{validationLabel}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={validationError !== undefined}
|
|
||||||
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
|
||||||
>
|
|
||||||
Add automation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExistingEventTriggersProps {
|
|
||||||
eventId: string;
|
|
||||||
triggers: Trigger[];
|
|
||||||
automations: NormalisedAutomation;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
|
|
||||||
const { updateEntry } = useEntryActionsContext();
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(triggerId: string) => {
|
|
||||||
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
|
||||||
updateEntry({ id: eventId, triggers: newTriggers });
|
|
||||||
},
|
|
||||||
[eventId, triggers, updateEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredTriggers: Record<string, Trigger[]> = {};
|
|
||||||
|
|
||||||
// sort triggers out into groups by the Lifecycle they are on
|
|
||||||
timerLifecycleValues.forEach((triggerType) => {
|
|
||||||
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
|
||||||
if (thisTriggerType.length) {
|
|
||||||
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.triggerList}>
|
|
||||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
|
||||||
<Fragment key={triggerLifeCycle}>
|
|
||||||
{triggerGroup.map((trigger) => {
|
|
||||||
const { id, automationId } = trigger;
|
|
||||||
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className={style.trigger}>
|
<div key={trigger.id} className={style.trigger} data-duplicate={isDuplicate || undefined}>
|
||||||
<div className={style.triggerMeta}>
|
<Select
|
||||||
<div className={style.metaLabel}>Lifecycle</div>
|
value={trigger.trigger}
|
||||||
<div>{triggerLifeCycle}</div>
|
onValueChange={(value) => {
|
||||||
</div>
|
if (value !== null) handleChange(trigger.id, 'trigger', value);
|
||||||
<div className={style.triggerMeta}>
|
}}
|
||||||
<div className={style.metaLabel}>Automation</div>
|
options={lifecycleOptions}
|
||||||
<div className={style.automationTitle}>{automationTitle}</div>
|
/>
|
||||||
</div>
|
<Select
|
||||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
value={trigger.automationId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== null) handleChange(trigger.id, 'automationId', value);
|
||||||
|
}}
|
||||||
|
options={automationOptions}
|
||||||
|
/>
|
||||||
|
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||||
<IoTrash />
|
<IoTrash />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Fragment>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
{duplicateIds.size > 0 && (
|
||||||
|
<span className={style.duplicateMessage}>
|
||||||
|
* Duplicate combinations will only fire once per lifecycle event.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{allAutomationOptions.length > 0 && (
|
||||||
|
<Button variant='ghosted' onClick={handleAdd}>
|
||||||
|
<IoAdd /> Add automation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,15 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTrigger.forEach((trigger) => {
|
// deduplicate — if the same automation appears multiple times on one lifecycle, fire it once
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const uniqueTriggers = filteredTrigger.filter((t) => {
|
||||||
|
if (seen.has(t.automationId)) return false;
|
||||||
|
seen.add(t.automationId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
uniqueTriggers.forEach((trigger) => {
|
||||||
const automation = automations[trigger.automationId];
|
const automation = automations[trigger.automationId];
|
||||||
if (!automation || automation.outputs.length === 0) {
|
if (!automation || automation.outputs.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user