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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
Claude
2026-08-08 11:08:29 +00:00
parent 5ea3845f0d
commit e7379c9add
2 changed files with 116 additions and 20 deletions
@@ -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<AutomationDTO | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(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 && (
<DeleteAutomationDialog
automation={deleteTarget}
blockingTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
)}
<Panel.SubHeader>
Manage automations
<Panel.InlineElements relation='inner'>
@@ -181,7 +182,7 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
onClick={() => setDeleteTarget(automation)}
>
<IoTrash />
</IconButton>
@@ -190,13 +191,6 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={6}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Section>
@@ -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<string | null>(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 (
<Dialog
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title='Delete automation'
bodyElements={
<Panel.Section>
<Panel.Paragraph>
Delete <strong>{automation.title}</strong>? This cannot be undone.
</Panel.Paragraph>
{blockingTriggers.length > 0 && (
<Info type='warning'>
<Info.Title>
{blockingTriggers.length === 1
? 'One trigger will be deleted with it'
: `${blockingTriggers.length} triggers will be deleted with it`}
</Info.Title>
<Info.Body>
{blockingTriggers.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`).join(', ')}
</Info.Body>
</Info>
)}
{error && (
<Info type='error'>
<Info.Title>Could not delete this automation</Info.Title>
<Info.Body>{error}</Info.Body>
<Info.Footer>
Automations attached to a single event have to be removed from that event first, in the event editor.
</Info.Footer>
</Info>
)}
</Panel.Section>
}
footerElements={
<>
<Button onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
{blockingTriggers.length > 0 ? 'Delete triggers and automation' : 'Delete'}
</Button>
</>
}
/>
);
}