Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/27356-studio-delete-flow.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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(
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
<AutomationsPillar packageId={PKG} readOnly={readOnly} />
</MemoryRouter>,
);
}

/** Open the ⋯ menu and pick Delete flow: the confirm dialog. */
async function openConfirm(): Promise<HTMLElement> {
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();
});
});
84 changes: 84 additions & 0 deletions ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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 the overlay editing of the Studio / Setup
* boundary record's phase 2 (#28045).
*/

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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="auto-item-more"
aria-label={t('engine.studio.if.more', locale)}
title={t('engine.studio.if.more', locale)}
className={STUDIO_HEADER_ICON_BUTTON}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
data-testid="auto-item-delete"
disabled={disabled}
className="text-destructive focus:text-destructive"
onSelect={() => setConfirming(true)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden="true" />
{t('engine.studio.auto.deleteFlow', locale)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={confirming} onOpenChange={setConfirming}>
<AlertDialogContent data-testid="auto-delete-confirm-dialog">
<AlertDialogHeader>
<AlertDialogTitle>{tFormat('engine.edit.deleteConfirm', locale, vars)}</AlertDialogTitle>
<AlertDialogDescription>{tFormat('engine.edit.deleteConfirmBody', locale, vars)}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="auto-delete-confirm-cancel">{t('engine.cancel', locale)}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
data-testid="auto-delete-confirm-action"
onClick={() => { setConfirming(false); onDelete(); }}
>
{t('engine.edit.delete', locale)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -9562,6 +9595,15 @@ export function AutomationsPillar({
)}
{flowSwitchEl}
{!flowTabsEl && autoReadOnlyLinkEl}
{current && flowLoaded && !readOnly && packageId !== null && (
<FlowItemMoreMenu
name={current.name}
label={current.label}
locale={locale}
disabled={!!saving}
onDelete={() => void deleteOpenFlow()}
/>
)}
</>
}
tabs={flowTabsEl}
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,8 @@ const STUDIO_EN: Record<string, string> = {
'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',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2171,6 +2171,8 @@ const STUDIO_ZH: Record<string, string> = {
'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': '已停用 — 不会运行',
Expand Down
Loading