From c090d5820ab221a8b8855e09f0f78a4094353363 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:51:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(studio):=20Delete=20flow=20in=20the=20?= =?UTF-8?q?Automations=20pillar's=20=E2=8B=AF=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open flow of a writable package gets a ⋯ menu with Delete flow: an in-app confirm naming the flow by label and flow/NAME, the pending draft's discard and the flow's delete through client.reset at the drafts' package, then a re-read rail with the pillar left on its empty state. A refusal shows the door's sentence and keeps the flow open; a read-only package draws no menu. Claude-Session: https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu Co-authored-by: Claude --- .changeset/27356-studio-delete-flow.md | 11 ++ ...utomationsPillar.deleteFlow-27356.test.tsx | 153 ++++++++++++++++++ .../views/studio-design/FlowItemMoreMenu.tsx | 83 ++++++++++ .../studio-design/StudioDesignSurface.tsx | 46 +++++- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 6 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 .changeset/27356-studio-delete-flow.md create mode 100644 ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx diff --git a/.changeset/27356-studio-delete-flow.md b/.changeset/27356-studio-delete-flow.md new file mode 100644 index 00000000000..934a278a3ff --- /dev/null +++ b/.changeset/27356-studio-delete-flow.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): Delete flow in the Automations pillar's ⋯ menu + +Clause-②: no +Metadata-protocol: none + +A flow authored in Studio could only be deleted from Setup › Metadata. An open flow of a writable package now has a ⋯ menu in its header with Delete flow. It asks in the console's in-app dialog, naming the flow by its label and `flow/NAME`, then discards the flow's pending draft and deletes the flow through the same `DELETE /meta/flow/:name` door Setup uses, at the package Studio writes the flow's drafts to. The rail is re-read, the pillar returns to its empty state without opening another flow, and a toast names the deleted flow. A refused delete shows the server's sentence and keeps the flow open. A read-only package's flow has no menu. Two keys are added to the studio namespace (en, zh): `engine.studio.auto.deleteFlow` and `engine.studio.auto.flowDeleted`. diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx new file mode 100644 index 00000000000..7577bb50f08 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27356 row 19 (maintainer-ruled A1) — Delete flow in the Automations + * pillar's ⋯ menu, on a writable package's open flow: an in-app confirm + * naming the flow (objectui#13333), the draft's discard and the flow's delete + * at the package (`client.reset`), then the empty state over a re-read rail. + * A refusal keeps the flow open; a read-only package draws no menu. The + * harness is `AutomationsPillar.showMeFocus-26730`'s, the server doubled at + * the metadata client. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { toast } from 'sonner'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +const FLOW = { + name: 'approval', + label: 'Approval', + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +const server = vi.hoisted(() => ({ draft: null as Record | null, deleted: false, refuse: null as Error | null })); + +const mockClient = vi.hoisted(() => ({ + // A second flow stays on the rail: the pillar must not open it after the delete. + list: vi.fn(async () => [...(server.deleted ? [] : [{ name: 'approval', label: 'Approval' }]), { name: 'escalate', label: 'Escalate' }]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (_t: string, name: string) => ({ code: null, overlay: null, effective: { ...FLOW, name, label: name === 'approval' ? 'Approval' : 'Escalate' }, lock: 'none' })), + getDraft: vi.fn(async (type: string, name: string) => { + if (server.draft) return { type, name, item: JSON.parse(JSON.stringify(server.draft)) }; + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + reset: vi.fn(async (_type: string, _name: string, options: { state?: string } = {}) => { + if (options.state === 'draft') server.draft = null; + else if (server.refuse) throw server.refuse; + else server.deleted = true; + return {}; + }), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; + +const dataSource = createEmptyDataSource(); + +// The pillar's `/automation/_status` probe: "absent", its documented fallback. +vi.stubGlobal('fetch', vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }))); + +beforeEach(() => { + server.draft = null; + server.deleted = false; + server.refuse = null; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + vi.mocked(toast.success).mockClear(); +}); + +afterEach(cleanup); + +function mount(readOnly = false) { + render( + + + , + ); +} + +/** Open the ⋯ menu and pick Delete flow: the confirm dialog. */ +async function openConfirm(): Promise { + fireEvent.pointerDown(await screen.findByTestId('auto-item-more', undefined, { timeout: 8000 }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByTestId('auto-item-delete')); + return screen.findByTestId('auto-delete-confirm-dialog'); +} +const deleteFromMenu = async () => fireEvent.click(within(await openConfirm()).getByTestId('auto-delete-confirm-action')); + +describe('Automations — Delete flow (#27356 row 19)', () => { + it('asks naming the flow, discards its draft, deletes it at the package, and leaves the pillar on its empty state', async () => { + server.draft = { ...FLOW, label: 'Approval' }; + mount(); + expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval'); + const listReads = mockClient.list.mock.calls.length; + + const dialog = await openConfirm(); + expect(dialog).toHaveTextContent('"Approval"'); + expect(dialog).toHaveTextContent('flow/approval'); + expect(mockClient.reset).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByTestId('auto-delete-confirm-action')); + + await waitFor(() => expect(mockClient.reset).toHaveBeenCalledTimes(2)); + expect(mockClient.reset.mock.calls).toEqual([ + ['flow', 'approval', { state: 'draft', packageId: PKG }], + ['flow', 'approval', { packageId: PKG }], + ]); + await waitFor(() => expect(mockClient.list.mock.calls.length).toBeGreaterThan(listReads)); + await waitFor(() => expect(screen.queryByText('Approval')).toBeNull()); + expect(screen.getByText('Escalate')).toBeInTheDocument(); + expect(mockClient.layered.mock.calls.map(([, name]) => name)).not.toContain('escalate'); + expect(screen.queryByTestId('auto-canvas-caption')).toBeNull(); + expect(screen.queryByTestId('auto-item-more')).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('Deleted flow "Approval"'); + }); + + it('a flow with no pending draft is deleted with the one call', async () => { + mount(); + await deleteFromMenu(); + await waitFor(() => expect(screen.queryByTestId('auto-canvas-caption')).toBeNull()); + expect(mockClient.reset.mock.calls).toEqual([['flow', 'approval', { packageId: PKG }]]); + }); + + it('a refused delete shows the door\'s sentence and keeps the flow open', async () => { + server.refuse = Object.assign(new Error('flow/approval is referenced by 2 triggers.'), { status: 409, code: 'RESOURCE_CONFLICT' }); + mount(); + await deleteFromMenu(); + expect(await screen.findByTestId('studio-refusal')).toHaveTextContent('flow/approval is referenced by 2 triggers.'); + expect(screen.getByTestId('auto-canvas-caption')).toHaveTextContent('Approval'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('a read-only package draws no ⋯ menu', async () => { + mount(true); + expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval'); + expect(screen.queryByTestId('auto-item-more')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx new file mode 100644 index 00000000000..bd98dbd2f3c --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * FlowItemMoreMenu — the Automations pillar's ⋯ menu on an open flow, with + * Delete flow (#27356 row 19, maintainer-ruled A1; Studio had no delete, + * objectui#12725). The confirmation is Setup's: the in-app dialog naming the + * flow by its label and `type/name` (objectui#13333). The pillar draws it only + * for a writable package's flow and sends the delete itself. Reset overlay is + * not offered: it arrives with ADR-0146 Phase 2's overlay editing. + */ + +import * as React from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@object-ui/components'; +import { MoreHorizontal, Trash2 } from 'lucide-react'; +import { t, tFormat, translateMetadataType } from '../metadata-admin/i18n.js'; +import { STUDIO_HEADER_ICON_BUTTON } from './studio-tokens.js'; + +/** `label` is the flow's display label; `disabled` holds the delete while a save is in flight. */ +export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete }: { + name: string; label: string; locale: string; disabled?: boolean; onDelete: () => void; +}): React.ReactElement { + const [confirming, setConfirming] = React.useState(false); + const vars = { type: 'flow', name, typeLabel: translateMetadataType('flow', locale), item: label || name }; + return ( + <> + + + + + + setConfirming(true)} + > + + + + + + + {tFormat('engine.edit.deleteConfirm', locale, vars)} + {tFormat('engine.edit.deleteConfirmBody', locale, vars)} + + + {t('engine.cancel', locale)} + { setConfirming(false); onDelete(); }} + > + {t('engine.edit.delete', locale)} + + + + + + ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 5707d36e015..95e9a7fd2a3 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -78,6 +78,7 @@ import { type StudioPendingDrafts, } from './studioItemDraftStatus.js'; import { packagedAutomationPath } from './BuilderLanding.js'; +import { FlowItemMoreMenu } from './FlowItemMoreMenu.js'; import { DATA_CONTENT_CONTAINER, DATA_TAB_FLAT_ONLY, DATA_TAB_FOLDED_ONLY } from './dataTabsFold.js'; import { StudioRail, StudioRailGroup, StudioRailRow } from './StudioRail.js'; import { StudioEmptyState, StudioEmptyStateAction } from './StudioEmptyState.js'; @@ -8822,6 +8823,10 @@ export function AutomationsPillar({ }; }, [publishNonce]); + // #27356 row 19 — a delete re-reads the rail (`flowListNonce`) and leaves + // the pillar on its empty state: that one re-read opens no other flow. + const [flowListNonce, setFlowListNonce] = React.useState(0); + const keepEmptyAfterDeleteRef = React.useRef(false); React.useEffect(() => { let cancelled = false; (async () => { @@ -8864,7 +8869,9 @@ export function AutomationsPillar({ return; } } - setCurrent((c) => c ?? deepLinked ?? (named ? null : items[0]) ?? null); + const keepEmpty = keepEmptyAfterDeleteRef.current; + keepEmptyAfterDeleteRef.current = false; + setCurrent((c) => (keepEmpty ? c : (c ?? deepLinked ?? (named ? null : items[0]) ?? null))); } catch (e) { if (!cancelled) setError(plainRefusal(e)); } finally { @@ -8874,7 +8881,7 @@ export function AutomationsPillar({ return () => { cancelled = true; }; - }, [client, packageId, publishNonce, metadataRefreshNonce, locale]); + }, [client, packageId, publishNonce, metadataRefreshNonce, locale, flowListNonce]); const doCreateFlow = React.useCallback( async (label: string, name: string, trigger: NewFlowTrigger | null) => { @@ -9119,6 +9126,32 @@ export function AutomationsPillar({ setCreating(true); } : undefined; + // #27356 row 19 — Delete flow (`FlowItemMoreMenu`): the autosave disarmed, + // the draft (so none is left to publish the flow back) and then the flow + // deleted at the drafts' package, then the empty state over a re-read rail. + // A refusal shows the door's sentence and keeps the flow open. + const deleteOpenFlow = React.useCallback(async () => { + if (!current || packageId === null) return; + const flow = current; + setAutoDirty(false); + setError(null); + try { + if (hasDraft) { + await client.reset('flow', flow.name, { state: 'draft', packageId }); + setHasDraft(false); + } + await client.reset('flow', flow.name, { packageId }); + } catch (e) { + setError(plainRefusal(e)); + return; + } + keepEmptyAfterDeleteRef.current = true; + setCurrent(null); + setFlows((fs) => fs.filter((f) => f.name !== flow.name)); + setFlowListNonce((n) => n + 1); + onDraftSaved?.({ type: 'flow', name: flow.name }); + toast.success(tFormat('engine.studio.auto.flowDeleted', locale, { item: flow.label || flow.name })); + }, [client, current, packageId, hasDraft, onDraftSaved, locale]); // objectstack#26917 — the refusal's way out: the flow holding the identifier // is on this rail, so it opens instead of a second one being made. The New // button already asked about a held step (objectstack#26845). @@ -9562,6 +9595,15 @@ export function AutomationsPillar({ )} {flowSwitchEl} {!flowTabsEl && autoReadOnlyLinkEl} + {current && flowLoaded && !readOnly && packageId !== null && ( + void deleteOpenFlow()} + /> + )} } tabs={flowTabsEl} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index dd1f38331bf..7c7b3082255 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2413,6 +2413,8 @@ const STUDIO_EN: Record = { 'engine.studio.auto.savedDraft': 'Automation “{label}” saved as draft', 'engine.studio.auto.defaultOff': 'Off by default · review before enabling', 'engine.studio.auto.heading': 'Automations', + 'engine.studio.auto.deleteFlow': 'Delete flow', + 'engine.studio.auto.flowDeleted': 'Deleted flow "{item}"', 'engine.studio.auto.on': 'On', 'engine.studio.auto.off': 'Off', 'engine.studio.auto.offTitle': 'Disabled — will not run', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 81a3388df38..45168260a74 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2171,6 +2171,8 @@ const STUDIO_ZH: Record = { 'engine.studio.auto.savedDraft': '自动化「{label}」已存为草稿', 'engine.studio.auto.defaultOff': '默认停用 · 审阅后再启用', 'engine.studio.auto.heading': '自动化', + 'engine.studio.auto.deleteFlow': '删除流程', + 'engine.studio.auto.flowDeleted': '已删除流程「{item}」', 'engine.studio.auto.on': '已启用', 'engine.studio.auto.off': '已停用', 'engine.studio.auto.offTitle': '已停用 — 不会运行', From ae7dc3db9d021d9276a348f9f3ebac119a900029 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:24:41 +0000 Subject: [PATCH 2/2] fix(studio): the Delete flow menu cites the Studio / Setup record's PR, not an unmerged ADR number check:adr-anchors refuses a citation of ADR-0146 while no record of that number is under docs/adr/; the record is still the draft #28045. Claude-Session: https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu Co-authored-by: Claude --- .../app-shell/src/views/studio-design/FlowItemMoreMenu.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx index bd98dbd2f3c..3df4523b37c 100644 --- a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx +++ b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx @@ -6,7 +6,8 @@ * objectui#12725). The confirmation is Setup's: the in-app dialog naming the * flow by its label and `type/name` (objectui#13333). The pillar draws it only * for a writable package's flow and sends the delete itself. Reset overlay is - * not offered: it arrives with ADR-0146 Phase 2's overlay editing. + * not offered: it arrives with the overlay editing of the Studio / Setup + * boundary record's phase 2 (#28045). */ import * as React from 'react';