From de63b75944b4d36e9bff8a6aa2925f4025f0f4b2 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 16 Oct 2026 13:48:10 -0400 Subject: [PATCH 1/5] Add Operation rename and empty-only delete Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 12 +- frontend/e2e/operations.spec.ts | 53 ++++++ frontend/src/App.tsx | 2 +- .../Operations/OperationDeleteDialog.tsx | 88 +++++++++ .../Operations/OperationDetailPage.test.tsx | 166 +++++++++++++++-- .../Operations/OperationDetailPage.tsx | 45 +++-- ...tionDialog.tsx => OperationEditDialog.tsx} | 51 ++++-- frontend/src/services/api.ts | 6 +- frontend/src/services/errors.test.ts | 24 ++- frontend/src/services/errors.ts | 29 ++- frontend/src/types/index.ts | 16 +- pyrit/backend/models/operations.py | 22 ++- pyrit/backend/routes/operations.py | 56 ++++-- pyrit/backend/services/operation_service.py | 40 ++++- pyrit/memory/memory_interface.py | 169 ++++++++++++++++-- pyrit/models/__init__.py | 3 +- pyrit/models/operation.py | 53 +++--- tests/unit/backend/test_operations.py | 84 ++++++++- .../memory/test_activity_operation_ids.py | 108 ++++++++++- tests/unit/memory/test_findings.py | 72 +++++++- 20 files changed, 971 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/Operations/OperationDeleteDialog.tsx rename frontend/src/components/Operations/{OperationDescriptionDialog.tsx => OperationEditDialog.tsx} (50%) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index c0bb92a8c8..a2fecb1477 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -552,7 +552,7 @@ In active runs and saved scenario results, **Atomic attack groups** defaults to Until you expand or collapse the section, its default follows the current group count as progress loads. Once you choose, the section keeps your choice during progress updates for the same run, even if the count crosses 20. Opening a different run resets to that run's count-based default. -### Operations and Findings +### Operations An operation groups related red-teaming work. Inside it you record findings: human assessments, whether or not an attack produced them. @@ -564,9 +564,13 @@ scope. The **Operation** dropdown in the labels bar applies the selected operation to new attacks and scanner runs, and **New operation…** at the top of that list creates one without leaving the page. -On an operation's page, **Add description** or **Edit description** changes the -description. Saving replaces it, so if two people edit at once, the last save wins. -Operations can't be renamed or deleted from the GUI. +Renaming doesn't update anything that stores the old name: Analytics filters and +links, history from before Operation IDs, and names set in configuration, +`GLOBAL_MEMORY_LABELS`, or `--memory-labels`. + +**Delete** works only when no findings, attacks, or scanner runs are linked; the +tooltip shows what's left. History from before Operation IDs doesn't count. +Deleting can't be undone. #### Findings diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts index a4656137d1..358cba1439 100644 --- a/frontend/e2e/operations.spec.ts +++ b/frontend/e2e/operations.spec.ts @@ -174,6 +174,59 @@ test('creates a unique operation and records findings within it @seeded', async await expect(page.getByRole('button', { name: 'New finding' })).toBeFocused() }) +test('renames an operation and deletes it only once it is empty @seeded', async ({ page, request }) => { + test.setTimeout(90_000) + const stamp = Date.now() + const version = await (await request.get('/api/version')).json() + const headers = { 'PyRIT-Compatibility-ID': version.compatibility_id } + const taken: Operation = await (await request.post('/api/operations', { + headers, data: { name: `Taken ${stamp}` }, + })).json() + const operation: Operation = await (await request.post('/api/operations', { + headers, data: { name: `Typo operation ${stamp}` }, + })).json() + const finding: Finding = await (await request.post(`/api/operations/${operation.id}/findings`, { + headers, data: { title: 'Blocks deletion', description: '', severity: 'low' }, + })).json() + + await page.goto(`/operations/${operation.id}`) + const remove = page.getByRole('button', { name: 'Delete', exact: true }) + await expect(remove).toBeDisabled() + await remove.focus() + await expect(page.getByRole('tooltip')).toHaveText('Has 1 finding. Only empty operations can be deleted.') + + const edit = page.getByRole('button', { name: 'Edit', exact: true }) + await edit.click() + let dialog = page.getByRole('dialog', { name: 'Edit operation' }) + await dialog.getByRole('textbox', { name: 'Name' }).fill(` taken ${stamp} `) + await dialog.getByRole('button', { name: 'Save operation' }).click() + await expect(dialog.getByText(`Another operation is already named “${taken.name}”`)).toBeVisible() + await dialog.getByRole('textbox', { name: 'Name' }).fill(operation.name.toUpperCase()) + await dialog.getByRole('button', { name: 'Save operation' }).click() + await expect(page.getByRole('heading', { level: 1, name: operation.name.toUpperCase() })).toBeVisible() + await expect(edit).toBeFocused() + const renamed = `Fixed operation ${stamp}` + await edit.click() + await dialog.getByRole('textbox', { name: 'Name' }).fill(renamed) + await dialog.getByRole('button', { name: 'Save operation' }).click() + await expect(page.getByRole('heading', { level: 1, name: renamed })).toBeVisible() + + await page.getByRole('button', { name: `Delete finding: ${finding.title}` }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Delete finding' }).click() + await expect(remove).toBeEnabled() + await remove.click() + dialog = page.getByRole('dialog', { name: 'Delete operation?' }) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(remove).toBeFocused() + await remove.click() + await dialog.getByRole('button', { name: 'Delete operation' }).click() + await expect(page).toHaveURL(/\/operations$/) + await expect(page.getByRole('heading', { name: 'Operations', exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: taken.name })).toBeVisible() + await expect(page.getByRole('link', { name: renamed })).toHaveCount(0) + expect((await request.get(`/api/operations/${operation.id}`, { headers })).status()).toBe(404) +}) + test('selects saved operations inline and revisits ID-scoped attack history @seeded', async ({ page, request }) => { test.setTimeout(90_000) const name = `Engagement / α% & ${Date.now()}` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccc38ab465..652d5aa458 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -848,7 +848,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { } /> } /> } /> - } /> + } /> } /> void + onDeleted: () => void +} + +function countLabel(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}` +} + +/** Why an Operation can't be deleted, or undefined when nothing refers to it. */ +function deleteBlockedReason({ findings, attacks, scenario_runs: runs }: OperationUsage): string | undefined { + const parts = [ + findings > 0 ? countLabel(findings, 'finding', 'findings') : '', + attacks + runs > 0 ? countLabel(attacks + runs, 'linked attack or run', 'linked attacks or runs') : '', + ].filter(Boolean) + return parts.length > 0 ? `Has ${parts.join(' and ')}. Only empty operations can be deleted.` : undefined +} + +export default function OperationDeleteDialog({ operation, onUsageChanged, onDeleted }: OperationDeleteDialogProps) { + const styles = useOperationsStyles() + const [open, setOpen] = useState(false) + const [error, setError] = useState('') + const [deleting, setDeleting] = useState(false) + const submitting = useRef(false) + const blockedReason = deleteBlockedReason(operation.usage) + + const remove = async (): Promise => { + if (submitting.current) return + submitting.current = true + setDeleting(true) + setError('') + try { + await operationsApi.delete(operation.id) + setOpen(false) + onDeleted() + } catch (cause: unknown) { + const usage = operationInUse(cause) + if (usage) onUsageChanged(usage) + setError((usage && deleteBlockedReason(usage)) || toApiError(cause).detail) + } finally { + submitting.current = false + setDeleting(false) + } + } + + return ( + { + if (deleting) return + if (data.open) setError('') + setOpen(data.open) + }}> + + + + + + + + Delete operation? + + Permanently delete “{operation.name}”? This cannot be undone. + {error && {error}} +
+ + + + +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/Operations/OperationDetailPage.test.tsx b/frontend/src/components/Operations/OperationDetailPage.test.tsx index 68c2f0deb3..589d1de46d 100644 --- a/frontend/src/components/Operations/OperationDetailPage.test.tsx +++ b/frontend/src/components/Operations/OperationDetailPage.test.tsx @@ -4,12 +4,12 @@ import { FluentProvider, webLightTheme } from '@fluentui/react-components' import { MemoryRouter, Route, Routes } from 'react-router' import { attacksApi, operationsApi, scenariosApi } from '@/services/api' -import type { Finding, FindingListItem, Operation } from '@/types' +import type { Finding, FindingListItem, Operation, OperationDetail } from '@/types' import OperationDetailPage from './OperationDetailPage' jest.mock('@/services/api', () => ({ operationsApi: { - get: jest.fn(), update: jest.fn(), listFindings: jest.fn(), createFinding: jest.fn(), + get: jest.fn(), update: jest.fn(), delete: jest.fn(), listFindings: jest.fn(), createFinding: jest.fn(), updateFinding: jest.fn(), deleteFinding: jest.fn(), listFindingEvidence: jest.fn(), detachFindingEvidence: jest.fn(), getFindingOptions: jest.fn(), @@ -18,20 +18,24 @@ jest.mock('@/services/api', () => ({ scenariosApi: { listRuns: jest.fn() }, })) -const OPERATION: Operation = { id: 'op-1', name: 'Red team / α%', created_at: '2026-10-06T20:00:00Z' } +const SAVED: Operation = { id: 'op-1', name: 'Red team / α%', created_at: '2026-10-06T20:00:00Z' } +const OPERATION: OperationDetail = { ...SAVED, usage: { findings: 0, attacks: 0, scenario_runs: 0 } } const FINDING: FindingListItem = { id: 'finding-1', operation_id: 'op-1', title: 'Human assessment', description: '', severity: 'informational', created_at: '2026-10-06T20:00:00Z', evidence_count: 0, } const EMPTY = { items: [], has_more: false, next_offset: null } const NOT_FOUND = { isAxiosError: true, response: { status: 404, data: { detail: 'missing' } } } +const conflict = (detail: object) => ({ isAxiosError: true, response: { status: 409, data: { detail } } }) -function renderPage(route = '/operations/op-1'): void { +function renderPage(route = '/operations/op-1', onOperationChanged?: () => void): void { render( - } /> + Operations list} /> + } /> , @@ -52,25 +56,127 @@ beforeEach(() => { jest.mocked(scenariosApi.listRuns).mockResolvedValue({ items: [], pagination: { limit: 5, has_more: false } }) }) -it('adds and edits the operation description, keeping the draft after a failed save', async () => { +it('renames the operation and edits its description, keeping the draft after a failed or conflicting save', async () => { const user = userEvent.setup() - renderPage() - await user.click(await screen.findByRole('button', { name: 'Add description' })) - let dialog = screen.getByRole('dialog') + const onOperationChanged = jest.fn() + renderPage(undefined, onOperationChanged) + await user.click(await screen.findByRole('button', { name: 'Edit' })) + let dialog = screen.getByRole('dialog', { name: 'Edit operation' }) + const name = within(dialog).getByRole('textbox', { name: 'Name' }) + expect(name).toHaveValue(OPERATION.name) expect(within(dialog).getByRole('textbox', { name: 'Description' })).toHaveValue('') + await user.clear(name) + await user.type(name, 'Engagement') await user.type(within(dialog).getByRole('textbox', { name: 'Description' }), 'Web app only') jest.mocked(operationsApi.update).mockRejectedValueOnce(new Error('Storage unavailable')) - await user.click(within(dialog).getByRole('button', { name: 'Save description' })) - expect(await within(dialog).findByText(/Could not save description: Storage unavailable/)).toBeInTheDocument() - jest.mocked(operationsApi.update).mockResolvedValueOnce({ ...OPERATION, description: 'Web app only' }) - await user.click(within(dialog).getByRole('button', { name: 'Save description' })) - expect(await screen.findByText('Web app only')).toBeInTheDocument() - expect(screen.getByText('Operation description saved.')).toBeInTheDocument() - expect(operationsApi.update).toHaveBeenLastCalledWith('op-1', { description: 'Web app only' }) + await user.click(within(dialog).getByRole('button', { name: 'Save operation' })) + expect(await within(dialog).findByText(/Could not save operation: Storage unavailable/)).toBeInTheDocument() + jest.mocked(operationsApi.update).mockRejectedValueOnce(conflict({ + message: 'taken', operation: { id: 'op-2', name: 'ENGAGEMENT', created_at: SAVED.created_at }, + })) + await user.click(within(dialog).getByRole('button', { name: 'Save operation' })) + expect(await within(dialog).findByText(/Another operation is already named “ENGAGEMENT”/)).toBeInTheDocument() + expect(name).toHaveValue('Engagement') + await user.type(name, ' 2') + expect(within(dialog).queryByText(/already named/)).not.toBeInTheDocument() + jest.mocked(operationsApi.update).mockResolvedValueOnce({ ...SAVED, name: 'Engagement 2', description: 'Web app only' }) + await user.click(within(dialog).getByRole('button', { name: 'Save operation' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Engagement 2' })).toBeInTheDocument() + expect(screen.getByText('Web app only')).toBeInTheDocument() + expect(screen.getByText('Operation updated.')).toBeInTheDocument() + expect(operationsApi.update).toHaveBeenLastCalledWith('op-1', { name: 'Engagement 2', description: 'Web app only' }) + expect(onOperationChanged).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'Delete' })).not.toHaveAttribute('aria-disabled') - await user.click(await screen.findByRole('button', { name: 'Edit description' })) - dialog = screen.getByRole('dialog') + await user.click(screen.getByRole('button', { name: 'Edit' })) + dialog = screen.getByRole('dialog', { name: 'Edit operation' }) + expect(within(dialog).getByRole('textbox', { name: 'Name' })).toHaveValue('Engagement 2') expect(within(dialog).getByRole('textbox', { name: 'Description' })).toHaveValue('Web app only') + await user.clear(within(dialog).getByRole('textbox', { name: 'Name' })) + expect(within(dialog).getByRole('button', { name: 'Save operation' })).toBeDisabled() +}) + +it('sends only the fields that were edited', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.update).mockResolvedValue({ ...SAVED, description: 'Web app only' }) + renderPage() + await user.click(await screen.findByRole('button', { name: 'Edit' })) + const dialog = screen.getByRole('dialog', { name: 'Edit operation' }) + await user.type(within(dialog).getByRole('textbox', { name: 'Description' }), 'Web app only') + await user.click(within(dialog).getByRole('button', { name: 'Save operation' })) + expect(await screen.findByText('Web app only')).toBeInTheDocument() + expect(operationsApi.update).toHaveBeenCalledWith('op-1', { description: 'Web app only' }) +}) + +it('deletes an empty operation after confirmation and returns to the operations list', async () => { + const user = userEvent.setup() + const onOperationChanged = jest.fn() + jest.mocked(operationsApi.delete).mockResolvedValue(undefined) + renderPage(undefined, onOperationChanged) + const remove = await screen.findByRole('button', { name: 'Delete' }) + expect(remove).toHaveAccessibleDescription('Delete this empty operation.') + await user.click(remove) + let dialog = screen.getByRole('dialog', { name: 'Delete operation?' }) + expect(within(dialog).getByText(/cannot be undone/i)).toHaveTextContent(OPERATION.name) + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(operationsApi.delete).not.toHaveBeenCalled() + await user.click(remove) + dialog = screen.getByRole('dialog', { name: 'Delete operation?' }) + await user.click(within(dialog).getByRole('button', { name: 'Delete operation' })) + // JSDOM can leave Tabster's modal aria-hidden after navigation; the seeded browser test checks the accessible page. + expect(await screen.findByText('Operations list')).toBeInTheDocument() + expect(operationsApi.delete).toHaveBeenCalledWith('op-1') + expect(onOperationChanged).toHaveBeenCalledTimes(1) +}) + +it.each([ + [{ findings: 3, attacks: 10, scenario_runs: 2 }, 'Has 3 findings and 12 linked attacks or runs.'], + [{ findings: 1, attacks: 0, scenario_runs: 0 }, 'Has 1 finding.'], + [{ findings: 0, attacks: 0, scenario_runs: 1 }, 'Has 1 linked attack or run.'], +])('keeps an operation in use undeletable and explains why: %o', async (usage, reason) => { + const user = userEvent.setup() + jest.mocked(operationsApi.get).mockResolvedValue({ ...OPERATION, usage }) + renderPage() + const remove = await screen.findByRole('button', { name: 'Delete' }) + expect(remove).toHaveAttribute('aria-disabled', 'true') + expect(remove).toHaveAccessibleDescription(`${reason} Only empty operations can be deleted.`) + remove.focus() + expect(remove).toHaveFocus() + await user.keyboard('{Enter}') + await user.click(remove) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(operationsApi.delete).not.toHaveBeenCalled() +}) + +it('shows the counts and disables deletion when the operation came into use after loading', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.delete).mockRejectedValueOnce(conflict({ + message: 'in use', usage: { findings: 0, attacks: 2, scenario_runs: 0 }, + })) + renderPage() + await user.click(await screen.findByRole('button', { name: 'Delete' })) + const dialog = screen.getByRole('dialog', { name: 'Delete operation?' }) + await user.click(within(dialog).getByRole('button', { name: 'Delete operation' })) + const reason = 'Has 2 linked attacks or runs. Only empty operations can be deleted.' + expect(await within(dialog).findByText(reason)).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Delete operation' })).toHaveAttribute('aria-disabled', 'true') + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + const remove = await screen.findByRole('button', { name: 'Delete' }) + expect(remove).toHaveAttribute('aria-disabled', 'true') + expect(remove).toHaveAccessibleDescription(reason) + expect(screen.getByRole('heading', { level: 1, name: OPERATION.name })).toBeInTheDocument() +}) + +it('reports other deletion failures in the confirmation dialog', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.delete).mockRejectedValueOnce(new Error('Storage unavailable')) + renderPage() + await user.click(await screen.findByRole('button', { name: 'Delete' })) + const dialog = screen.getByRole('dialog', { name: 'Delete operation?' }) + await user.click(within(dialog).getByRole('button', { name: 'Delete operation' })) + expect(await within(dialog).findByText('Storage unavailable')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Delete operation' })).not.toHaveAttribute('aria-disabled') }) it('refreshes evidence counts without collapsing the expanded list after detachment', async () => { @@ -323,6 +429,30 @@ it('requires delete confirmation and refreshes the first page after deleting its await waitFor(() => expect(screen.getByRole('button', { name: 'New finding' })).toHaveFocus()) }) +it('refreshes operation usage after findings change so an emptied operation can be deleted', async () => { + const user = userEvent.setup() + const usage = { findings: 1, attacks: 0, scenario_runs: 0 } + jest.mocked(operationsApi.get).mockResolvedValue({ ...OPERATION, usage }) + jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] }) + renderPage() + expect(await screen.findByRole('button', { name: 'Delete' })).toHaveAttribute('aria-disabled', 'true') + await user.click(screen.getByRole('button', { name: `Delete finding: ${FINDING.title}` })) + jest.mocked(operationsApi.get).mockResolvedValue(OPERATION) + jest.mocked(operationsApi.listFindings).mockResolvedValue(EMPTY) + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete finding' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' })).not.toHaveAttribute('aria-disabled')) + expect(operationsApi.get).toHaveBeenCalledTimes(2) + + jest.mocked(operationsApi.get).mockRejectedValue(new Error('Busy')) + await user.click(screen.getByRole('button', { name: 'New finding' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), FINDING.title) + await user.click(within(dialog).getByRole('button', { name: 'Save finding' })) + await waitFor(() => expect(operationsApi.get).toHaveBeenCalledTimes(3)) + expect(await screen.findByRole('heading', { level: 1, name: OPERATION.name })).toBeInTheDocument() + expect(screen.queryByText(/Could not load operation/)).not.toBeInTheDocument() +}) + it('keeps deletion errors visible and prevents duplicate confirmations', async () => { const user = userEvent.setup() jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] }) diff --git a/frontend/src/components/Operations/OperationDetailPage.tsx b/frontend/src/components/Operations/OperationDetailPage.tsx index 9cfaf01164..14221e7ecc 100644 --- a/frontend/src/components/Operations/OperationDetailPage.tsx +++ b/frontend/src/components/Operations/OperationDetailPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Link, useParams, useSearchParams } from 'react-router' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, MessageBar, MessageBarBody, Spinner, Text, @@ -9,27 +9,34 @@ import { ArrowLeftRegular } from '@fluentui/react-icons' import { DEFAULT_HISTORY_FILTERS, filtersToSearchParams } from '@/components/History/historyFilters' import { operationsApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { Finding, FindingCreate, FindingListItem, FindingListResponse, Operation } from '@/types' +import type { Finding, FindingCreate, FindingListItem, FindingListResponse, OperationDetail } from '@/types' import FindingDialog, { type FindingSaveAttempt } from './FindingDialog' import FindingEvidenceList from './FindingEvidenceList' import FindingSeverityBadge from './FindingSeverityBadge' -import OperationDescriptionDialog from './OperationDescriptionDialog' +import OperationDeleteDialog from './OperationDeleteDialog' +import OperationEditDialog from './OperationEditDialog' import { useOperationsStyles } from './Operations.styles' const PAGE_SIZE = 20 type OperationState = - | { key: string; status: 'loaded'; operation: Operation } + | { key: string; status: 'loaded'; operation: OperationDetail } | { key: string; status: 'missing' } | { key: string; status: 'error'; error: string } -export default function OperationDetailPage() { +interface OperationDetailPageProps { + /** Called after the Operation is renamed or deleted, so views holding it can refresh. */ + onOperationChanged?: () => void +} + +export default function OperationDetailPage({ onOperationChanged }: OperationDetailPageProps) { const { operationId = '' } = useParams() - return + return } -function OperationDetailContent({ operationId }: { operationId: string }) { +function OperationDetailContent({ operationId, onOperationChanged }: { operationId: string } & OperationDetailPageProps) { const styles = useOperationsStyles() + const navigate = useNavigate() const [params, setParams] = useSearchParams() const rawOffset = Number(params.get('offset') ?? 0) const offset = Number.isSafeInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0 @@ -70,12 +77,13 @@ function OperationDetailContent({ operationId }: { operationId: string }) { .catch((cause: unknown) => { if (ignore) return const apiError = toApiError(cause) - setOperationState(apiError.status === 404 - ? { key: operationKey, status: 'missing' } - : { key: operationKey, status: 'error', error: apiError.detail }) + // A failed usage refresh after a finding change keeps the page; the server still checks before deleting. + setOperationState(state => state?.key === operationKey && state.status === 'loaded' ? state + : apiError.status === 404 ? { key: operationKey, status: 'missing' } + : { key: operationKey, status: 'error', error: apiError.detail }) }) return () => { ignore = true } - }, [operationId, operationKey]) + }, [operationId, operationKey, revision]) useEffect(() => { let ignore = false @@ -183,10 +191,19 @@ function OperationDetailContent({ operationId }: { operationId: string }) { {current.operation.name}
- { - setOperationState({ key: operationKey, status: 'loaded', operation }) - setNotice('Operation description saved.') + { + setOperationState({ key: operationKey, status: 'loaded', operation: { ...current.operation, ...operation } }) + setNotice('Operation updated.') + onOperationChanged?.() }} /> + { + setOperationState({ key: operationKey, status: 'loaded', operation: { ...current.operation, usage } }) + }} + onDeleted={() => { + onOperationChanged?.() + void navigate('/operations') + }} /> + - Operation description + Edit operation
{ event.preventDefault(); void save() }}> {error && - Could not save description: {error} + Could not save operation: {error} } + {existing && + Another operation is already named “{existing.name}”. Choose a different name. + } + + { setName(data.value); setExisting(null) }} /> +