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={ + <> + + + + } + /> + ); +}