From e7379c9add6d17227c89304041c569646df835f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 11:08:29 +0000 Subject: [PATCH] refactor(automation): explain a refused deletion instead of dumping the error Deleting an automation had no confirmation at all, and when the server refused because something still referenced it, the raw message landed in a stray row underneath the table. Deletion now confirms first, and names what it will take with it. Global triggers are ours to clean up, so the dialog offers to delete them along with the automation. A reference from a rundown event is not: editing rundown data from a settings screen would be surprising and hard to undo, so that stays a block, with the server's message and a pointer to the event editor. This matters more once the demo project ships with automations, since those are referenced by definition and every user who tries to remove one would otherwise meet the raw error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H --- .../automations-panel/AutomationsList.tsx | 34 +++--- .../DeleteAutomationDialog.tsx | 102 ++++++++++++++++++ 2 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index 89e1fc256..fa9053336 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -2,8 +2,6 @@ import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime import { Fragment, useEffect, useMemo, useState } from 'react'; import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5'; -import { deleteAutomation } from '../../../../common/api/automation'; -import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import IconButton from '../../../../common/components/buttons/IconButton'; import Info from '../../../../common/components/info/Info'; @@ -15,6 +13,7 @@ import { summariseOutputs } from '../../../../common/utils/automationOutputs'; import * as Panel from '../../panel-utils/PanelUtils'; import AutomationForm from './AutomationForm'; import { groupTriggersByAutomation } from './automationUtils'; +import DeleteAutomationDialog from './DeleteAutomationDialog'; import RecipeLibraryModal from './recipes/RecipeLibraryModal'; import style from './AutomationsList.module.scss'; @@ -37,7 +36,7 @@ export default function AutomationsList({ automations, triggers, enabledAutomati const { refetch } = useAutomationSettings(); const [automationFormData, setAutomationFormData] = useState(null); const [showRecipes, setShowRecipes] = useState(false); - const [deleteError, setDeleteError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); /** * A recipe lands in the editor rather than only in the list. @@ -50,15 +49,9 @@ export default function AutomationsList({ automations, triggers, enabledAutomati setAutomationFormData(created); }; - const handleDelete = async (id: string) => { - try { - setDeleteError(null); - await deleteAutomation(id); - } catch (error) { - setDeleteError(maybeAxiosError(error)); - } finally { - refetch(); - } + const handleDeleted = async () => { + setDeleteTarget(null); + await refetch(); }; const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); @@ -82,6 +75,14 @@ export default function AutomationsList({ automations, triggers, enabledAutomati onInstalled={(_recipe, created) => handleRecipeInstalled(created)} /> )} + {deleteTarget !== null && ( + trigger.automationId === deleteTarget.id)} + onCancel={() => setDeleteTarget(null)} + onDeleted={handleDeleted} + /> + )} Manage automations @@ -181,7 +182,7 @@ export default function AutomationsList({ automations, triggers, enabledAutomati handleDelete(automationId)} + onClick={() => setDeleteTarget(automation)} > @@ -190,13 +191,6 @@ export default function AutomationsList({ automations, triggers, enabledAutomati ); })} - {deleteError && ( - - - {deleteError} - - - )} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx new file mode 100644 index 000000000..5db34ff68 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx @@ -0,0 +1,102 @@ +import type { Automation, Trigger } from 'ontime-types'; +import { useState } from 'react'; + +import { deleteAutomation, deleteTrigger } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Button from '../../../../common/components/buttons/Button'; +import Dialog from '../../../../common/components/dialog/Dialog'; +import Info from '../../../../common/components/info/Info'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; +import * as Panel from '../../panel-utils/PanelUtils'; + +interface DeleteAutomationDialogProps { + automation: Automation; + /** global triggers pointing at this automation, they block the delete server side */ + blockingTriggers: Trigger[]; + onCancel: () => void; + onDeleted: () => void; +} + +/** + * The server refuses to delete an automation that is still referenced, and the panel used to + * dump that refusal into a stray row under the table. Global triggers are ours to clean up, so + * we offer to do it. A reference from a rundown event is not: editing rundown data from a + * settings screen would be a surprising, hard to undo action, so that stays a block with an + * explanation of where to go. + */ +export default function DeleteAutomationDialog({ + automation, + blockingTriggers, + onCancel, + onDeleted, +}: DeleteAutomationDialogProps) { + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setError(null); + setIsDeleting(true); + + try { + for (const trigger of blockingTriggers) { + await deleteTrigger(trigger.id); + } + await deleteAutomation(automation.id); + onDeleted(); + } catch (error) { + setError(maybeAxiosError(error)); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + Delete {automation.title}? This cannot be undone. + + + {blockingTriggers.length > 0 && ( + + + {blockingTriggers.length === 1 + ? 'One trigger will be deleted with it' + : `${blockingTriggers.length} triggers will be deleted with it`} + + + {blockingTriggers.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`).join(', ')} + + + )} + + {error && ( + + Could not delete this automation + {error} + + Automations attached to a single event have to be removed from that event first, in the event editor. + + + )} + + } + footerElements={ + <> + + + + } + /> + ); +}