Skip to content

Commit 062a232

Browse files
feat(studio): an open flow in Studio can be deleted from its ⋯ menu (#28126)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:Studio 自动化里打开一个可写软件包的流程,页头多了一个 ⋯ 菜单,里面是「删除流程」。点了先弹出控制台内的确认框(和 Setup 一样,写出流程名和 `flow/名字`),确认后先丢弃这个流程的待发布草稿,再删除流程;左栏重新读取,页面回到空状态,提示「已删除流程「…」」。只读包(代码包、已安装包)的流程不画这个菜单。 - 为什么改:#27356 第 19 行,按你的裁决 A1(评论 6520616687):Studio 先能删除流程;Reset overlay、启停开关和复制随 ADR-0146 第 2 阶段(#28045)再做。 - 风险与回滚:只在可写包的流程上多一个删除入口,走的是 Setup 删除用的同一个 `DELETE /meta/flow/:name`,带上 Studio 写草稿用的同一个包;服务端拒绝时显示服务端原话,流程保持打开。回滚就是 revert 本 PR。 - 先看:`StudioDesignSurface.tsx` 里 `AutomationsPillar` 的 `deleteOpenFlow`(`#27356 row 19` 注释处),以及新文件 `FlowItemMoreMenu.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx`,应见 4 passed。 - 需要你做的:无,席位落地。 Part of #27356 (row 19, hotcrm#2320 Studio S8: Studio can delete a flow; maintainer-ruled A1, comment 6520616687) Clause-②: no Metadata-protocol: none ## What changes - **A ⋯ menu on an open flow.** In the Automations pillar, the header of an open flow of a writable package gets a ⋯ button (`auto-item-more`, aria 「More」, the header's icon-button token) after the Enabled switch. Its one item is 「Delete flow」 (`auto-item-delete`, Trash icon, destructive colour). It is disabled while a save is in flight, so the delete never races the autosave. Before this, a flow could be deleted only from Setup › Metadata (objectui#12725). - **The confirmation is Setup's.** The in-app `AlertDialog` (objectui#13333; never the browser's `confirm()`) reuses Setup's own keys, `engine.edit.deleteConfirm` / `engine.edit.deleteConfirmBody` / `engine.edit.delete`, with the same variables Setup's `ResourceEditPage` passes: the flow's label, `flow/NAME` and the type's label. Cancel closes it with nothing sent. - **The delete.** `deleteOpenFlow` disarms the autosave (`setAutoDirty(false)`). When the flow has a pending draft, it first discards it with `client.reset('flow', name, { state: 'draft', packageId })`, so no draft is left to publish the flow back. It then deletes the flow with `client.reset('flow', name, { packageId })`, which is `DELETE /meta/flow/:name`, the door Setup's delete uses. `packageId` is the package Studio writes the flow's drafts to (objectui#12335's binding). - **After the delete.** The pillar clears the open flow, drops it from the rail and re-reads the list (`flowListNonce`). A one-shot ref keeps that re-read from opening another flow, so the pillar lands on its empty state. `onDraftSaved` is called so the shell's pending-drafts count re-reads, and a toast names the flow: 「Deleted flow "LABEL"」. - **A refusal.** A refused delete (for example `409 RESOURCE_CONFLICT`) shows the door's own sentence in the pillar's refusal strip, the same `plainRefusal` path every other refused write here uses, and the flow stays open. - **A read-only package draws nothing**, and neither does a flow that has not finished loading or a pillar with no package binding. - **No Reset overlay.** Per the ruling, Reset overlay, the Enabled toggle and Clone on a packaged flow arrive with ADR-0146 Phase 2's overlay editing (#28045), not here. - **i18n.** Two keys added to the studio namespace (en, zh): `engine.studio.auto.deleteFlow` (en 「Delete flow」, zh 「删除流程」) and `engine.studio.auto.flowDeleted` (en 「Deleted flow "{item}"」, zh 「已删除流程「{item}」」). Other locales fall back to en, as the namespace allows. No key renamed, no en value changed. ## Tests - New `AutomationsPillar.deleteFlow-27356.test.tsx` (4 tests) on the real `AutomationsPillar`, the server doubled at the metadata client (the harness of `AutomationsPillar.showMeFocus-26730`). The rail serves two flows, so that opening the second one after the delete would show. - With a pending draft: the dialog names "Approval" and `flow/approval`; nothing is sent before Delete; then the two calls are the draft discard and the delete, in that order, at the package; the list is re-read; "Approval" leaves the rail, "Escalate" stays and is NOT opened (no `layered` read of it, no canvas caption); the toast names the flow. - No pending draft: the one delete call. - Refused (`409`): the refusal strip shows the server's sentence, the flow stays open, no toast. - Read-only package: the flow opens and there is no ⋯ menu. <details><summary>Readings (head c090d58, branch rebuilt from origin/main 6b125a7 after #28076 merged; install and app-shell closure rebuilt)</summary> - New file: 4/4 passed. - Every ui test file naming `StudioDesignSurface`, `AutomationsPillar` or `FlowItemMoreMenu` (209 files): 209 passed, 1561 passed. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/i18n run type-check`: exit 0 (lock VERDICT command-exit 0). - Ablation 1, through `scripts/ablation-replace.mjs`: the draft discard `await client.reset('flow', flow.name, { state: 'draft', packageId });` to `/* ABLATED */`: 1 failed / 3 passed (the pending-draft case red: one call, not two); restored blob = HEAD blob `95e9a7fd2a3e`, `git diff HEAD` empty. - Ablation 2: `(keepEmpty ? c : (c ??` to `(/* ABLATED */ (c ??` (the re-read opens the next flow): 1 failed / 3 passed (the pending-draft case red: "Escalate" opens, so its name shows twice); restored, `git diff HEAD` empty. - vitest resolves `@object-ui/*` to source, so neither ablation needed a rebuild. - `pnpm --dir ui run check:i18n-drift`: no en value changed. </details> ## Acceptance notes - **The card stays open.** After this PR every Studio row of #27356 (12–22) is taken, carded or answered. The Setup rows 1, 4, 5, 6, 7, 8, 10 and 11 have no card or answer yet (seat 2's note 6488880391), so this PR is `Part of`, not `Fixes`. - **Draft first, then the flow.** Setup's delete sends only the delete. Studio also discards the flow's pending draft first, because Studio's autosave keeps one and a draft left behind could publish the flow back. If the draft discard succeeds and the delete is then refused, the flow stays open without its draft: what was published is unchanged, and the unsaved draft edits are gone. That case is not separately pinned. - **The ⋯ menu holds one item today.** Reset overlay, the Enabled toggle and Clone join it with ADR-0146 Phase 2. - Measured in happy-dom against a doubled metadata client, not a running server: the `409` sentence is the test's, and whether the server refuses a flow other items reference was not measured here. ## Verification On head `c090d582`: `dispatch-gates --commands` derived 34 families for this diff; all 34 were run locally, each exit 0, and `--ran` reconciled 34 run, 0 NOT-MEASURED, 0 UNRUN. `check:new-line-citations`: 0 new. CI check-runs were in progress when this was opened. The harness's after-PR assign and review-request step is skipped on the order's word. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 146115a commit 062a232

6 files changed

Lines changed: 296 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): Delete flow in the Automations pillar's ⋯ menu
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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`.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #27356 row 19 (maintainer-ruled A1) — Delete flow in the Automations
5+
* pillar's ⋯ menu, on a writable package's open flow: an in-app confirm
6+
* naming the flow (objectui#13333), the draft's discard and the flow's delete
7+
* at the package (`client.reset`), then the empty state over a re-read rail.
8+
* A refusal keeps the flow open; a read-only package draws no menu. The
9+
* harness is `AutomationsPillar.showMeFocus-26730`'s, the server doubled at
10+
* the metadata client.
11+
*/
12+
13+
import '@testing-library/jest-dom/vitest';
14+
import * as React from 'react';
15+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16+
import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
17+
import { toast } from 'sonner';
18+
import { MemoryRouter } from 'react-router-dom';
19+
20+
const PKG = 'com.acme.app';
21+
22+
const FLOW = {
23+
name: 'approval',
24+
label: 'Approval',
25+
type: 'autolaunched',
26+
status: 'active',
27+
nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
28+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
29+
};
30+
31+
const server = vi.hoisted(() => ({ draft: null as Record<string, unknown> | null, deleted: false, refuse: null as Error | null }));
32+
33+
const mockClient = vi.hoisted(() => ({
34+
// A second flow stays on the rail: the pillar must not open it after the delete.
35+
list: vi.fn(async () => [...(server.deleted ? [] : [{ name: 'approval', label: 'Approval' }]), { name: 'escalate', label: 'Escalate' }]),
36+
listDrafts: vi.fn(async () => []),
37+
listTypes: vi.fn(async () => ({ entries: [] })),
38+
get: vi.fn(async () => null),
39+
references: vi.fn(async () => []),
40+
layered: vi.fn(async (_t: string, name: string) => ({ code: null, overlay: null, effective: { ...FLOW, name, label: name === 'approval' ? 'Approval' : 'Escalate' }, lock: 'none' })),
41+
getDraft: vi.fn(async (type: string, name: string) => {
42+
if (server.draft) return { type, name, item: JSON.parse(JSON.stringify(server.draft)) };
43+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
44+
}),
45+
save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })),
46+
reset: vi.fn(async (_type: string, _name: string, options: { state?: string } = {}) => {
47+
if (options.state === 'draft') server.draft = null;
48+
else if (server.refuse) throw server.refuse;
49+
else server.deleted = true;
50+
return {};
51+
}),
52+
}));
53+
54+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
55+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
56+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
57+
});
58+
59+
vi.mock('./packages-io', async (importOriginal) => {
60+
const mod = await importOriginal<typeof import('./packages-io')>();
61+
return { ...mod, fetchPackages: vi.fn(async () => []) };
62+
});
63+
64+
vi.mock('@object-ui/react', async (importOriginal) => {
65+
const mod = await importOriginal<typeof import('@object-ui/react')>();
66+
return { ...mod, useAdapter: () => dataSource };
67+
});
68+
69+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
70+
71+
import { AutomationsPillar } from './StudioDesignSurface';
72+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
73+
74+
const dataSource = createEmptyDataSource();
75+
76+
// The pillar's `/automation/_status` probe: "absent", its documented fallback.
77+
vi.stubGlobal('fetch', vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })));
78+
79+
beforeEach(() => {
80+
server.draft = null;
81+
server.deleted = false;
82+
server.refuse = null;
83+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
84+
vi.mocked(toast.success).mockClear();
85+
});
86+
87+
afterEach(cleanup);
88+
89+
function mount(readOnly = false) {
90+
render(
91+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
92+
<AutomationsPillar packageId={PKG} readOnly={readOnly} />
93+
</MemoryRouter>,
94+
);
95+
}
96+
97+
/** Open the ⋯ menu and pick Delete flow: the confirm dialog. */
98+
async function openConfirm(): Promise<HTMLElement> {
99+
fireEvent.pointerDown(await screen.findByTestId('auto-item-more', undefined, { timeout: 8000 }), { button: 0, ctrlKey: false });
100+
fireEvent.click(await screen.findByTestId('auto-item-delete'));
101+
return screen.findByTestId('auto-delete-confirm-dialog');
102+
}
103+
const deleteFromMenu = async () => fireEvent.click(within(await openConfirm()).getByTestId('auto-delete-confirm-action'));
104+
105+
describe('Automations — Delete flow (#27356 row 19)', () => {
106+
it('asks naming the flow, discards its draft, deletes it at the package, and leaves the pillar on its empty state', async () => {
107+
server.draft = { ...FLOW, label: 'Approval' };
108+
mount();
109+
expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval');
110+
const listReads = mockClient.list.mock.calls.length;
111+
112+
const dialog = await openConfirm();
113+
expect(dialog).toHaveTextContent('"Approval"');
114+
expect(dialog).toHaveTextContent('flow/approval');
115+
expect(mockClient.reset).not.toHaveBeenCalled();
116+
fireEvent.click(within(dialog).getByTestId('auto-delete-confirm-action'));
117+
118+
await waitFor(() => expect(mockClient.reset).toHaveBeenCalledTimes(2));
119+
expect(mockClient.reset.mock.calls).toEqual([
120+
['flow', 'approval', { state: 'draft', packageId: PKG }],
121+
['flow', 'approval', { packageId: PKG }],
122+
]);
123+
await waitFor(() => expect(mockClient.list.mock.calls.length).toBeGreaterThan(listReads));
124+
await waitFor(() => expect(screen.queryByText('Approval')).toBeNull());
125+
expect(screen.getByText('Escalate')).toBeInTheDocument();
126+
expect(mockClient.layered.mock.calls.map(([, name]) => name)).not.toContain('escalate');
127+
expect(screen.queryByTestId('auto-canvas-caption')).toBeNull();
128+
expect(screen.queryByTestId('auto-item-more')).toBeNull();
129+
expect(toast.success).toHaveBeenCalledWith('Deleted flow "Approval"');
130+
});
131+
132+
it('a flow with no pending draft is deleted with the one call', async () => {
133+
mount();
134+
await deleteFromMenu();
135+
await waitFor(() => expect(screen.queryByTestId('auto-canvas-caption')).toBeNull());
136+
expect(mockClient.reset.mock.calls).toEqual([['flow', 'approval', { packageId: PKG }]]);
137+
});
138+
139+
it('a refused delete shows the door\'s sentence and keeps the flow open', async () => {
140+
server.refuse = Object.assign(new Error('flow/approval is referenced by 2 triggers.'), { status: 409, code: 'RESOURCE_CONFLICT' });
141+
mount();
142+
await deleteFromMenu();
143+
expect(await screen.findByTestId('studio-refusal')).toHaveTextContent('flow/approval is referenced by 2 triggers.');
144+
expect(screen.getByTestId('auto-canvas-caption')).toHaveTextContent('Approval');
145+
expect(toast.success).not.toHaveBeenCalled();
146+
});
147+
148+
it('a read-only package draws no ⋯ menu', async () => {
149+
mount(true);
150+
expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval');
151+
expect(screen.queryByTestId('auto-item-more')).toBeNull();
152+
});
153+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* FlowItemMoreMenu — the Automations pillar's ⋯ menu on an open flow, with
5+
* Delete flow (#27356 row 19, maintainer-ruled A1; Studio had no delete,
6+
* objectui#12725). The confirmation is Setup's: the in-app dialog naming the
7+
* flow by its label and `type/name` (objectui#13333). The pillar draws it only
8+
* for a writable package's flow and sends the delete itself. Reset overlay is
9+
* not offered: it arrives with the overlay editing of the Studio / Setup
10+
* boundary record's phase 2 (#28045).
11+
*/
12+
13+
import * as React from 'react';
14+
import {
15+
AlertDialog,
16+
AlertDialogAction,
17+
AlertDialogCancel,
18+
AlertDialogContent,
19+
AlertDialogDescription,
20+
AlertDialogFooter,
21+
AlertDialogHeader,
22+
AlertDialogTitle,
23+
DropdownMenu,
24+
DropdownMenuContent,
25+
DropdownMenuItem,
26+
DropdownMenuTrigger,
27+
} from '@object-ui/components';
28+
import { MoreHorizontal, Trash2 } from 'lucide-react';
29+
import { t, tFormat, translateMetadataType } from '../metadata-admin/i18n.js';
30+
import { STUDIO_HEADER_ICON_BUTTON } from './studio-tokens.js';
31+
32+
/** `label` is the flow's display label; `disabled` holds the delete while a save is in flight. */
33+
export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete }: {
34+
name: string; label: string; locale: string; disabled?: boolean; onDelete: () => void;
35+
}): React.ReactElement {
36+
const [confirming, setConfirming] = React.useState(false);
37+
const vars = { type: 'flow', name, typeLabel: translateMetadataType('flow', locale), item: label || name };
38+
return (
39+
<>
40+
<DropdownMenu>
41+
<DropdownMenuTrigger asChild>
42+
<button
43+
type="button"
44+
data-testid="auto-item-more"
45+
aria-label={t('engine.studio.if.more', locale)}
46+
title={t('engine.studio.if.more', locale)}
47+
className={STUDIO_HEADER_ICON_BUTTON}
48+
>
49+
<MoreHorizontal className="h-4 w-4" />
50+
</button>
51+
</DropdownMenuTrigger>
52+
<DropdownMenuContent align="end" className="w-44">
53+
<DropdownMenuItem
54+
data-testid="auto-item-delete"
55+
disabled={disabled}
56+
className="text-destructive focus:text-destructive"
57+
onSelect={() => setConfirming(true)}
58+
>
59+
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden="true" />
60+
{t('engine.studio.auto.deleteFlow', locale)}
61+
</DropdownMenuItem>
62+
</DropdownMenuContent>
63+
</DropdownMenu>
64+
<AlertDialog open={confirming} onOpenChange={setConfirming}>
65+
<AlertDialogContent data-testid="auto-delete-confirm-dialog">
66+
<AlertDialogHeader>
67+
<AlertDialogTitle>{tFormat('engine.edit.deleteConfirm', locale, vars)}</AlertDialogTitle>
68+
<AlertDialogDescription>{tFormat('engine.edit.deleteConfirmBody', locale, vars)}</AlertDialogDescription>
69+
</AlertDialogHeader>
70+
<AlertDialogFooter>
71+
<AlertDialogCancel data-testid="auto-delete-confirm-cancel">{t('engine.cancel', locale)}</AlertDialogCancel>
72+
<AlertDialogAction
73+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
74+
data-testid="auto-delete-confirm-action"
75+
onClick={() => { setConfirming(false); onDelete(); }}
76+
>
77+
{t('engine.edit.delete', locale)}
78+
</AlertDialogAction>
79+
</AlertDialogFooter>
80+
</AlertDialogContent>
81+
</AlertDialog>
82+
</>
83+
);
84+
}

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
type StudioPendingDrafts,
7979
} from './studioItemDraftStatus.js';
8080
import { packagedAutomationPath } from './BuilderLanding.js';
81+
import { FlowItemMoreMenu } from './FlowItemMoreMenu.js';
8182
import { DATA_CONTENT_CONTAINER, DATA_TAB_FLAT_ONLY, DATA_TAB_FOLDED_ONLY } from './dataTabsFold.js';
8283
import { StudioRail, StudioRailGroup, StudioRailRow } from './StudioRail.js';
8384
import { StudioEmptyState, StudioEmptyStateAction } from './StudioEmptyState.js';
@@ -8822,6 +8823,10 @@ export function AutomationsPillar({
88228823
};
88238824
}, [publishNonce]);
88248825

8826+
// #27356 row 19 — a delete re-reads the rail (`flowListNonce`) and leaves
8827+
// the pillar on its empty state: that one re-read opens no other flow.
8828+
const [flowListNonce, setFlowListNonce] = React.useState(0);
8829+
const keepEmptyAfterDeleteRef = React.useRef(false);
88258830
React.useEffect(() => {
88268831
let cancelled = false;
88278832
(async () => {
@@ -8864,7 +8869,9 @@ export function AutomationsPillar({
88648869
return;
88658870
}
88668871
}
8867-
setCurrent((c) => c ?? deepLinked ?? (named ? null : items[0]) ?? null);
8872+
const keepEmpty = keepEmptyAfterDeleteRef.current;
8873+
keepEmptyAfterDeleteRef.current = false;
8874+
setCurrent((c) => (keepEmpty ? c : (c ?? deepLinked ?? (named ? null : items[0]) ?? null)));
88688875
} catch (e) {
88698876
if (!cancelled) setError(plainRefusal(e));
88708877
} finally {
@@ -8874,7 +8881,7 @@ export function AutomationsPillar({
88748881
return () => {
88758882
cancelled = true;
88768883
};
8877-
}, [client, packageId, publishNonce, metadataRefreshNonce, locale]);
8884+
}, [client, packageId, publishNonce, metadataRefreshNonce, locale, flowListNonce]);
88788885

88798886
const doCreateFlow = React.useCallback(
88808887
async (label: string, name: string, trigger: NewFlowTrigger | null) => {
@@ -9119,6 +9126,32 @@ export function AutomationsPillar({
91199126
setCreating(true);
91209127
}
91219128
: undefined;
9129+
// #27356 row 19 — Delete flow (`FlowItemMoreMenu`): the autosave disarmed,
9130+
// the draft (so none is left to publish the flow back) and then the flow
9131+
// deleted at the drafts' package, then the empty state over a re-read rail.
9132+
// A refusal shows the door's sentence and keeps the flow open.
9133+
const deleteOpenFlow = React.useCallback(async () => {
9134+
if (!current || packageId === null) return;
9135+
const flow = current;
9136+
setAutoDirty(false);
9137+
setError(null);
9138+
try {
9139+
if (hasDraft) {
9140+
await client.reset('flow', flow.name, { state: 'draft', packageId });
9141+
setHasDraft(false);
9142+
}
9143+
await client.reset('flow', flow.name, { packageId });
9144+
} catch (e) {
9145+
setError(plainRefusal(e));
9146+
return;
9147+
}
9148+
keepEmptyAfterDeleteRef.current = true;
9149+
setCurrent(null);
9150+
setFlows((fs) => fs.filter((f) => f.name !== flow.name));
9151+
setFlowListNonce((n) => n + 1);
9152+
onDraftSaved?.({ type: 'flow', name: flow.name });
9153+
toast.success(tFormat('engine.studio.auto.flowDeleted', locale, { item: flow.label || flow.name }));
9154+
}, [client, current, packageId, hasDraft, onDraftSaved, locale]);
91229155
// objectstack#26917 — the refusal's way out: the flow holding the identifier
91239156
// is on this rail, so it opens instead of a second one being made. The New
91249157
// button already asked about a held step (objectstack#26845).
@@ -9562,6 +9595,15 @@ export function AutomationsPillar({
95629595
)}
95639596
{flowSwitchEl}
95649597
{!flowTabsEl && autoReadOnlyLinkEl}
9598+
{current && flowLoaded && !readOnly && packageId !== null && (
9599+
<FlowItemMoreMenu
9600+
name={current.name}
9601+
label={current.label}
9602+
locale={locale}
9603+
disabled={!!saving}
9604+
onDelete={() => void deleteOpenFlow()}
9605+
/>
9606+
)}
95659607
</>
95669608
}
95679609
tabs={flowTabsEl}

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,8 @@ const STUDIO_EN: Record<string, string> = {
24132413
'engine.studio.auto.savedDraft': 'Automation “{label}” saved as draft',
24142414
'engine.studio.auto.defaultOff': 'Off by default · review before enabling',
24152415
'engine.studio.auto.heading': 'Automations',
2416+
'engine.studio.auto.deleteFlow': 'Delete flow',
2417+
'engine.studio.auto.flowDeleted': 'Deleted flow "{item}"',
24162418
'engine.studio.auto.on': 'On',
24172419
'engine.studio.auto.off': 'Off',
24182420
'engine.studio.auto.offTitle': 'Disabled — will not run',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2171,6 +2171,8 @@ const STUDIO_ZH: Record<string, string> = {
21712171
'engine.studio.auto.savedDraft': '自动化「{label}」已存为草稿',
21722172
'engine.studio.auto.defaultOff': '默认停用 · 审阅后再启用',
21732173
'engine.studio.auto.heading': '自动化',
2174+
'engine.studio.auto.deleteFlow': '删除流程',
2175+
'engine.studio.auto.flowDeleted': '已删除流程「{item}」',
21742176
'engine.studio.auto.on': '已启用',
21752177
'engine.studio.auto.off': '已停用',
21762178
'engine.studio.auto.offTitle': '已停用 — 不会运行',

0 commit comments

Comments
 (0)