From 741ebef90ecfa6392bd39b90e170e202b0117b3d Mon Sep 17 00:00:00 2001 From: Cameron Slipp Date: Mon, 11 May 2026 13:55:01 -0400 Subject: [PATCH] refactor: improve automation UX --- .../composite/EventEditorTriggers.module.scss | 74 ++---- .../composite/EventEditorTriggers.tsx | 244 ++++++------------ .../api-data/automation/automation.service.ts | 10 +- 3 files changed, 110 insertions(+), 218 deletions(-) diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss index 398c0cd6b..6aa4185e2 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.module.scss @@ -4,18 +4,6 @@ gap: 0.75rem; } -.section, -.formSection { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.sectionTitle { - font-size: $aux-text-size; - color: $label-gray; -} - .triggerList { display: flex; flex-direction: column; @@ -25,25 +13,13 @@ overflow: hidden; } -.triggerForm { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.formFields { +.triggerHeader { display: grid; - grid-template-columns: 8rem 1fr; - gap: 0.75rem; - align-items: end; -} - -.formActions { - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.75rem; - min-height: 2rem; + grid-template-columns: 8rem 1fr 2rem; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + font-size: $aux-text-size; + color: $label-gray; } .trigger { @@ -51,40 +27,22 @@ display: grid; grid-template-columns: 8rem 1fr 2rem; align-items: center; + gap: 0.5rem; min-height: 2.5rem; &:not(:first-child) { border-top: 1px solid $white-10; } + + &[data-duplicate] { + > button:not(:last-child) { + border-color: $orange-500; + } + } } -.triggerMeta { - min-width: 0; -} - -.metaLabel { - color: $label-gray; +.duplicateMessage { + padding-left: 0.75rem; font-size: $aux-text-size; - line-height: 1.2; - 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; + color: $orange-500; } diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx index a9ef7e537..eae835caa 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTriggers.tsx @@ -1,11 +1,9 @@ -import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types'; +import { Trigger } from 'ontime-types'; import { generateId } from 'ontime-utils'; -import { Fragment, useCallback, useMemo, useState } from 'react'; -import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5'; +import { IoAdd, IoTrash } from 'react-icons/io5'; import Button from '../../../../common/components/buttons/Button'; import IconButton from '../../../../common/components/buttons/IconButton'; -import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import Info from '../../../../common/components/info/Info'; import Select from '../../../../common/components/select/Select'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; @@ -21,174 +19,102 @@ interface EventEditorTriggersProps { export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) { const { data: automationSettings, status: automationStatus } = useAutomationSettings(); + const { updateEntry } = useEntryActionsContext(); 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(); + const seen = new Map(); + 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 (
{automationsEnabled === false && ( Automations are disabled. Event triggers stay configured, but they will not run until enabled. )} - {showTriggers && ( -
-
Applied automations
- -
- )} - -
Add automation
- -
-
- ); -} - -interface EventTriggerFormProps { - eventId: string; - triggers?: Trigger[]; - automations: NormalisedAutomation; -} - -function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) { - const { updateEntry } = useEntryActionsContext(); - const [automationId, setAutomationId] = useState(undefined); - const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); - - const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => { - const newTriggers = triggers ?? new Array(); - 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 ( -
-
-
- Lifecycle - { - if (value !== null) setAutomationId(value); - }} - options={automationOptions} - /> -
-
- -
-
- {validationError ? : } - {validationLabel} -
- -
-
- ); -} - -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 = {}; - - // 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 ( -
- {Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => ( - - {triggerGroup.map((trigger) => { - const { id, automationId } = trigger; - const automationTitle = automations[automationId]?.title ?? ''; + {triggers.length > 0 && ( +
+
+ Lifecycle + Automation +
+ {triggers.map((trigger) => { + const isDuplicate = duplicateIds.has(trigger.id); + const lifecycleOptions = isDuplicate + ? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt)) + : triggerOptions; + const automationOptions = isDuplicate + ? allAutomationOptions.map((opt) => + opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt, + ) + : allAutomationOptions; return ( -
-
-
Lifecycle
-
{triggerLifeCycle}
-
-
-
Automation
-
{automationTitle}
-
- handleDelete(id)}> +
+ { + if (value !== null) handleChange(trigger.id, 'automationId', value); + }} + options={automationOptions} + /> + handleDelete(trigger.id)}>
); })} - - ))} +
+ )} + {duplicateIds.size > 0 && ( + + * Duplicate combinations will only fire once per lifecycle event. + + )} + {allAutomationOptions.length > 0 && ( + + )}
); } diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index 91b0aee26..77efa24da 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -47,7 +47,15 @@ export function triggerAutomations(cycle: TimerLifeCycle) { return; } - filteredTrigger.forEach((trigger) => { + // deduplicate — if the same automation appears multiple times on one lifecycle, fire it once + const seen = new Set(); + 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]; if (!automation || automation.outputs.length === 0) { return;