Skip to content

Commit 17acfbb

Browse files
feat(core,app-shell,plugin-view,i18n): the delete confirmation names the record and confirms with a destructive Delete (objectui#11695) (#11744)
Fixes #11695 Clause-②: yes Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` (os-dev seat `domain:ui#3`, PM loop round 1). ## What changes A record delete confirmation now names what it deletes and confirms with a destructive "Delete" button: - **One record:** the title names the object label and the record's display name. en: `Delete Product "QA Widget 0"?`. zh: `删除Product“QA Widget 0”?`. - **A selection:** the title counts it: `Delete 3 Product records?` (zh `删除 3 条 Product 记录?`). - **Confirm button:** it reads "Delete" (zh 删除) and is painted with `buttonVariants({ variant: 'destructive' })`. - **Body:** unchanged. It is the plain delete question, the batch question, or ADR-0094's reset question for a package-owned permission set. Every delete dialog takes this copy from one builder, `recordDelete.confirmCopy(deps, target)` in `@object-ui/core`. It sits beside the existing `confirmText`, which is now its one-record body. The name comes from `getRecordDisplayName`, the ADR-0079 resolver the record page already uses for its title. Each host passes its object definition, so a declared `nameField` wins. | Surface | Producer | What it does now | |---|---|---| | Console object list, row Delete and bulk Delete (the card's repro) | `@object-ui/app-shell` `useObjectActions`, called by app-shell `ObjectView` | `deleteRecord` and the new `deleteRecords` ask `onConfirm` with the copy and `destructive: true`, then run the delete through the runner with no `confirmText` | | Record page Delete | `@object-ui/app-shell` `RecordDetailView`, the `sys_delete` header action | Same copy, read from the readable row (a denied name field reads as absent). A package-owned permission set now gets the reset question here too | | Registered `object-view` grid Delete | `@object-ui/plugin-view` `ObjectView`, `deleteConfirmDialog` | Same copy, with the confirm button painted destructive | | The console's confirm dialog | `@object-ui/app-shell` `ActionConfirmDialog` | Paints its confirm button destructive when `options.destructive` is set. Its options type is now the published `ConfirmationHandler` options, not a copy | The `ConfirmationHandler` options in `@object-ui/core` gain `destructive?: boolean`. Every other confirmation keeps "Confirm Action" / "Continue" in the primary style. The `actionConfirm.*` text is unchanged. `packages/components/src/ui/alert-dialog.tsx` is not touched (No-Touch zone, AGENTS.md #7). The destructive style is a className override at each call site. objectui#8978's `actionVariant` uses the same approach in the `alert-dialog` renderer. ## Premise check on origin/main `0cfe772`, hypothesis by hypothesis 1. plugin-view's `deleteConfirmDialog` used `actionConfirm.title` / `actionConfirm.confirm`, with `recordDelete.confirmText` as the body. **Held.** 2. `actionConfirm.title` / `.confirm` are shared. **Held.** `ActionConfirmDialog` falls back to them for every action-runner confirmation. Their text is unchanged; the delete has its own keys. 3. **The detail page's producer** is `RecordDetailView` in `packages/app-shell`, not `packages/plugin-detail`. Its `sys_delete` action asked `detail.deleteConfirmation` through the page's confirm handler with one argument. - **The card's repro path (console list, row menu, Delete) never reaches plugin-view.** The console list's `onDelete` calls `useObjectActions.deleteRecord`, which handed the action runner a `confirmText`. - The runner calls the confirm handler with one argument. So the console dialog could only ever show its generic title and "Continue". - Both producers are changed here. The record page falls under the claim's "the dev locates it" clause. The console list's producer adds three app-shell files: `useObjectActions.ts`, `ActionConfirmDialog.tsx` and `ObjectView.tsx`. 4. The title resolver exists: `getRecordDisplayName` (`@object-ui/core`). **Reused**, no new resolver. 5. objectui#11728 (`objectPluralLabel`) was still open when this was built (REST read before implementation: `merged: false`). So the batch title uses the singular label with a count family that mirrors `objectActions.bulkDeleteSuccess` (`Delete 3 Product records?`). That is the same shape as the toast that follows. It has merged since (main `6be0f7a`). The Acceptance notes explain why this PR still keeps the count-agreeing noun. objectui#8978: the card body says it "added the destructive confirm button to `alert-dialog`", and the triage says there is "no destructive variant". Both are partly right: - It added `actionVariant` to the `alert-dialog` **renderer** (`packages/components/src/renderers/overlay/alert-dialog.tsx`), as a className override. - The `ui/alert-dialog.tsx` **primitive** still bakes in `buttonVariants()` with no variant. ## Keys - **New in all ten packs**, under `objectActions`: - `deleteConfirmTitle` (`{{label}}`, `{{name}}`); - the `bulkDeleteConfirmTitle` count family (`{{count}}`, `{{label}}`). Each pack's slots mirror its own `bulkDeleteSuccess` family: es/fr/pt `_many`, ru `_few`/`_many`, ar all six; - `deleteConfirmButton`. - **Unchanged:** no existing value changed and no key was removed. - **plugin-view:** its English map for hosts without a translation provider gains the new rows. It drops `actionConfirm.title` / `.confirm`, which that host no longer asks for. - **de quote census:** `de-quote-pairing-3876.test.ts` moves from 70 to 71 for the title's one matched „…“ pair. The console list now passes its translated object label to `useObjectActions` (`objectLabel(objectDef)`, the label its header shows), so the delete toasts use that label too. With no translation bundle it is `objectDef.label`, as before. ## Tests and gates The package suites ran at `c8f5c14`. The final HEAD `c3110be` differs from it only by a type annotation in one test file. The changed test files and the app-shell type-check were re-run at `c3110be`. | Check | HEAD | Result | |---|---|---| | `pnpm exec vitest run packages/app-shell/ --shard=N/4`, N = 1..4 | `c8f5c14` | 259 + 259 + 259 + 259 files: 2435 (+1 skipped), 2309 (+8 skipped), 3021, 2440 tests passed; 0 failed | | `pnpm exec vitest run packages/core/ packages/i18n/ packages/plugin-view/` | `c8f5c14` | 342 files, 5833 passed, 40 skipped, 0 failed | | the six changed test files | `c3110be` | 6 files, 56 tests passed | | `pnpm --filter @object-ui/core type-check` / `@object-ui/i18n` | `c8f5c14` | exit 0 / exit 0 | | `pnpm --filter @object-ui/plugin-view type-check` after `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-view^...' build` | `c8f5c14` | exit 0 | | `pnpm --filter @object-ui/app-shell type-check` after building its closure | `c3110be` | exit 0, both `tsc --noEmit` and `tsc -p tsconfig.test.json`. Its `--listFiles` includes the three changed app-shell tests | | `pnpm exec eslint` over the 23 changed TS files | `c3110be` | 0 errors; 0 findings on added lines | | `check:control-bytes`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:i18n-designer-parity`, `check:new-line-citations`, `scripts/check-changeset-presence.mjs` | `c3110be` | all exit 0 | | `check:readme-exports` | `c3110be` | **NOT MEASURED.** The gate refuses because 94 documented self-imports point into packages that are not built. This diff adds exports and edits no README | **Console compile: narrowed, not run.** - `apps/console` resolves `@object-ui/*` through built `dist`; its tsconfig has no `paths`. - Its source names none of the changed symbols. A search for `useObjectActions|ActionConfirmDialog|ConfirmDialogState|ConfirmationHandler|recordDelete` in `apps/console/src` finds one hit, in a comment. - The control search for `from '@object-ui/app-shell'` does find the console's real imports. - So the console reads only app-shell's emitted declarations, and that source passes the type-check above. ## Reverse verification (ablation) Every leg ran through objectstack's `scripts/ablation-replace.mjs` in WRAP mode, which checks three things: - the anchor must hit; - the file's blob must change; - the restore is proven by the blob matching HEAD and an empty `git diff HEAD`. The expected direction for every leg was red. | Leg | Mutation (anchor hit once, blob changed) | Result | |---|---|---| | 1, destructive paint | `ActionConfirmDialog`: the `options.destructive` className replaced by `className={undefined}` | 6 failed, 4 passed. Red: the console list's row and batch in en and zh, and the record page in en and zh, all on `bg-destructive`. The primary-style controls stayed green | | 2, delete label | `recordDelete.confirmCopy`: `deleteConfirmButton` replaced by `actionConfirm.confirm` | 16 failed, 22 passed, across core (copy mismatch), the console list, the record page and plugin-view (no "Delete" button found) | | 3, name field | `useObjectActions.deleteRecord`: `objectDef` dropped from the copy's deps | 2 failed, 4 passed. The row title read `Delete Product "SKU-1"?` (a record key) instead of the declared `nameField` value | | type-level | `RecordDetailView`: `destructive: true` misspelled `destructiv` | `tsc --noEmit` reports TS2561 on that key against the rebuilt `ConfirmationHandler` options | ## Acceptance notes - **Plural label.** objectui#11728 landed after this was built, but the batch title deliberately keeps "N {{label}} records" rather than "N {{pluralLabel}}". - With "records", count agreement lives in a pack noun that each language inflects (ru записи/записей, ar six forms). - A metadata plural label has one form per object, so it cannot agree with a count. - It is the same pattern as the toast that follows. - Changing to "Delete 3 Products?" would only change pack values, not code. - **Bulk body.** The batch dialog's body is still `console.objectView.bulkDeleteConfirm` ("Delete N selected records? This cannot be undone."), so the title and the body both ask. The claim limited locale edits to new delete keys, so no existing value was changed. - **Ask before `execute`.** `useObjectActions` now asks before `execute`, so the hook's `loading` is false while the dialog is open. No code in the repo reads that hook's `loading` or `error`. A cancel still returns the runner's former `{ success: false, error: 'Action cancelled by user' }`. - **Nameless rows.** A row with no resolvable name shows the resolver's English `Record #id` fallback, the same one every other surface shows. - **Same class, not touched.** Recorded only, no card filed, carrier: none: - `plugin-detail` `DetailView` still deletes through the native `window.confirm`, with the authorable `deleteConfirmation` or the generic `detail.deleteConfirmation`. That is the drawer and panel detail used by gantt rows and similar. - app-shell's "delete saved view" confirm says "Delete" but uses the primary style. - After a package-owned permission set is reset, the record page's delete still toasts "Record deleted". The list's shared `recordDelete.run` toasts the reset. Not measured through a public entry point. - **plugin-view's label** is still the raw `objectSchema.label`, the one its form titles and toasts already use. - **Merge with current main** (`ded4494`) is textually clean (`git merge-tree`). main's de pack quote count is unchanged since the base, so the census pin does not collide. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c876f0 commit 17acfbb

25 files changed

Lines changed: 761 additions & 87 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/app-shell': minor
4+
'@object-ui/plugin-view': minor
5+
'@object-ui/i18n': minor
6+
---
7+
8+
A record delete confirmation now names what it deletes and confirms with a destructive "Delete" button (objectui#11695).
9+
10+
Deleting a row from an object list, a selection of rows, or a record from its own page opened a dialog titled "Confirm Action" with a "Continue" button in the primary style, and nothing in it said which record was about to go. The dialog now:
11+
12+
- titles one record by the object label and the record's display name, for example `Delete Product "QA Widget 0"?`. The name comes from the same resolver the record header and lookups use, so an object's declared `nameField` is honoured;
13+
- titles a selection by its size and the object label, for example `Delete 3 Product records?`;
14+
- labels its confirm button "Delete" and paints it in the destructive button style.
15+
16+
The body is unchanged: the plain delete question, the batch question, or, for a package-owned permission set, the reset question from ADR-0094. The record page's Delete now asks that same question, so a package-owned permission set deleted from its own page also gets the reset question instead of the plain one.
17+
18+
The three dialogs (the console list's row and bulk Delete, the record page's Delete, and the registered `object-view`'s grid Delete) take this copy from one place:
19+
20+
- `@object-ui/core`: `recordDelete.confirmCopy(deps, target)` returns `{ title, message, confirmText }` for one record (`{ record }`) or a batch (`{ count }`). The `ConfirmationHandler` options gain `destructive?: boolean`.
21+
- `@object-ui/app-shell`: `ActionConfirmDialog` paints the confirm button destructive when `options.destructive` is set. `useObjectActions` accepts `objectDef` and returns `deleteRecords(records)` for a confirmed batch delete. `deleteRecord` now asks through `onConfirm` with the full copy before the delete runs, rather than through the action runner's one-argument confirm. The console list passes its translated object label, so the delete toasts read the same label the page header shows.
22+
- `@object-ui/i18n`: new keys in all ten packs: `objectActions.deleteConfirmTitle`, the `objectActions.bulkDeleteConfirmTitle` count family, and `objectActions.deleteConfirmButton`.
23+
24+
Other confirmations are unchanged. They keep the `actionConfirm.*` title and "Continue" button and the primary style.

‎content/docs/guide/console-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ await execute({
143143
```
144144

145145
The `ActionRunner` supports:
146-
- **Confirmation** — async `ConfirmationHandler` (default: `window.confirm`, override with Shadcn AlertDialog)
146+
- **Confirmation** — async `ConfirmationHandler` (default: `window.confirm`, override with Shadcn AlertDialog). A caller that asks the handler directly may pass `options`: `title`, `confirmText` and `cancelText` replace the dialog's generic copy, and `destructive: true` paints the confirm button in the destructive style. The console's record delete asks this way: `recordDelete.confirmCopy` (`@object-ui/core`) builds a title naming the record (or counting a selection) and a "Delete" confirm label, and both the list and the record page show that copy.
147147
- **Toast notifications** — `ToastHandler` for success/error messages
148148
- **Custom handlers** — register domain-specific action types (e.g., `'create'`, `'delete'`, `'refresh'`)
149149

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11695 — the console list's Delete names the record it deletes and
11+
* confirms with a destructive Delete, in `en` and in `zh`.
12+
*
13+
* The console's object list deletes through `useObjectActions`: a row's Delete
14+
* calls `deleteRecord`, a selection's calls `deleteRecords`, and both ask
15+
* through the console runtime's confirm handler, which opens the in-app
16+
* `ActionConfirmDialog`. That dialog used to read "Confirm Action" / "Continue"
17+
* with the plain question, in the primary button style, for every delete.
18+
*
19+
* ## What makes these pins discriminating
20+
*
21+
* - The chain is REAL from the hook to the pixels: `useObjectActions`, the
22+
* shared `recordDelete` core, `ActionConfirmDialog` and the shipped packs
23+
* under a real `I18nProvider`. The one stand-in is the confirm handler, and
24+
* it is the console runtime's own body (`useConsoleActionRuntime`'s
25+
* `confirmHandler`: open the dialog with the message and options, settle on
26+
* the answer), mounted without the rest of that runtime.
27+
* - The record carries TWO name-ish values: its declared `nameField`
28+
* (`product_name`) and a `name`. The title must show the declared one, so a
29+
* host that stopped handing `objectDef` through would read `SKU-1` and fail.
30+
* - Strings are read from the packs by KEY and interpolated here, so the pin is
31+
* that the dialog shows THESE keys with THIS record and label, not what the
32+
* keys happen to say today. The named subject is asserted on its own too.
33+
* - The destructive style is read off the confirm button's class, with a
34+
* CONTROL: a confirmation that does not ask for it keeps the primary style,
35+
* so the class check is shown able to fail.
36+
*/
37+
38+
import * as React from 'react';
39+
import { describe, it, expect, vi, afterEach } from 'vitest';
40+
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
41+
import '@testing-library/jest-dom';
42+
import { MemoryRouter } from 'react-router-dom';
43+
import { I18nProvider } from '@object-ui/i18n';
44+
import { builtInLocales } from '@object-ui/i18n/locales';
45+
import type { ConfirmationHandler } from '@object-ui/core';
46+
47+
vi.mock('sonner', () => ({
48+
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
49+
}));
50+
51+
import { useObjectActions } from '../useObjectActions';
52+
import { ActionConfirmDialog, type ConfirmDialogState } from '../../views/ActionConfirmDialog';
53+
54+
type Pack = {
55+
objectActions: {
56+
deleteConfirm: string;
57+
deleteConfirmTitle: string;
58+
deleteConfirmButton: string;
59+
bulkDeleteConfirmTitle_other: string;
60+
};
61+
actionConfirm: { title: string; confirm: string; cancel: string };
62+
};
63+
const PACKS = {
64+
en: builtInLocales.en as unknown as Pack,
65+
zh: builtInLocales.zh as unknown as Pack,
66+
};
67+
68+
const fill = (template: string, vars: Record<string, string | number>) =>
69+
template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(vars[k]));
70+
71+
const PRODUCT = {
72+
name: 'crm_product',
73+
label: 'Product',
74+
nameField: 'product_name',
75+
fields: { product_name: { type: 'text' }, name: { type: 'text' } },
76+
};
77+
const ROW = { id: 'p1', product_name: 'QA Widget 0', name: 'SKU-1' };
78+
const BATCH = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
79+
80+
function ListDeleteHarness({ dataSource }: { dataSource: { delete: ReturnType<typeof vi.fn> } }) {
81+
const [state, setState] = React.useState<ConfirmDialogState>({ open: false, message: '' });
82+
// `useConsoleActionRuntime`'s confirm handler, verbatim in shape.
83+
const onConfirm = React.useCallback<ConfirmationHandler>(
84+
(message, options) =>
85+
new Promise<boolean>((resolve) => {
86+
setState({ open: true, message, options, resolve });
87+
}),
88+
[],
89+
);
90+
const actions = useObjectActions({
91+
objectName: PRODUCT.name,
92+
objectLabel: PRODUCT.label,
93+
objectDef: PRODUCT,
94+
dataSource,
95+
onConfirm,
96+
});
97+
return (
98+
<>
99+
<button onClick={() => void actions.deleteRecord(ROW.id, ROW)}>row-delete</button>
100+
<button onClick={() => void actions.deleteRecords(BATCH)}>bulk-delete</button>
101+
<button onClick={() => void onConfirm('Proceed?')}>plain-confirm</button>
102+
<ActionConfirmDialog
103+
state={state}
104+
onOpenChange={(open) => {
105+
if (!open) setState((s) => ({ ...s, open: false }));
106+
}}
107+
/>
108+
</>
109+
);
110+
}
111+
112+
function mount(language: keyof typeof PACKS) {
113+
const dataSource = { delete: vi.fn(async () => ({})), findOne: vi.fn() };
114+
render(
115+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
116+
<MemoryRouter initialEntries={['/apps/crm/crm_product']}>
117+
<ListDeleteHarness dataSource={dataSource} />
118+
</MemoryRouter>
119+
</I18nProvider>,
120+
);
121+
return dataSource;
122+
}
123+
124+
afterEach(() => {
125+
cleanup();
126+
vi.clearAllMocks();
127+
});
128+
129+
describe.each(['en', 'zh'] as const)('console list Delete in %s (objectui#11695)', (language) => {
130+
const pack = PACKS[language];
131+
132+
it('a row names the record by its declared name field and the object label, with a destructive Delete', async () => {
133+
const dataSource = mount(language);
134+
fireEvent.click(screen.getByText('row-delete'));
135+
136+
const dialog = await screen.findByRole('alertdialog');
137+
const title = fill(pack.objectActions.deleteConfirmTitle, { label: 'Product', name: 'QA Widget 0' });
138+
await waitFor(() => expect(within(dialog).getByRole('heading')).toHaveTextContent(title));
139+
// The named subject itself, independent of the template's words.
140+
expect(within(dialog).getByRole('heading')).toHaveTextContent('QA Widget 0');
141+
expect(within(dialog).getByRole('heading')).not.toHaveTextContent(pack.actionConfirm.title);
142+
expect(within(dialog).getByText(pack.objectActions.deleteConfirm)).toBeInTheDocument();
143+
144+
const confirm = within(dialog).getByRole('button', { name: pack.objectActions.deleteConfirmButton });
145+
expect(confirm.className).toContain('bg-destructive');
146+
expect(within(dialog).queryByRole('button', { name: pack.actionConfirm.confirm })).toBeNull();
147+
expect(dataSource.delete).not.toHaveBeenCalled();
148+
149+
fireEvent.click(confirm);
150+
await waitFor(() => expect(dataSource.delete).toHaveBeenCalledWith('crm_product', 'p1'));
151+
});
152+
153+
it('a selection counts the batch in the title, with the same destructive Delete', async () => {
154+
const dataSource = mount(language);
155+
fireEvent.click(screen.getByText('bulk-delete'));
156+
157+
const dialog = await screen.findByRole('alertdialog');
158+
const title = fill(pack.objectActions.bulkDeleteConfirmTitle_other, { count: 3, label: 'Product' });
159+
await waitFor(() => expect(within(dialog).getByRole('heading')).toHaveTextContent(title));
160+
const confirm = within(dialog).getByRole('button', { name: pack.objectActions.deleteConfirmButton });
161+
expect(confirm.className).toContain('bg-destructive');
162+
163+
fireEvent.click(confirm);
164+
await waitFor(() => expect(dataSource.delete).toHaveBeenCalledTimes(3));
165+
});
166+
167+
it('control: a confirmation that does not ask for `destructive` keeps the primary confirm', async () => {
168+
mount(language);
169+
fireEvent.click(screen.getByText('plain-confirm'));
170+
171+
const dialog = await screen.findByRole('alertdialog');
172+
await waitFor(() =>
173+
expect(within(dialog).getByRole('button', { name: pack.actionConfirm.confirm })).toBeInTheDocument(),
174+
);
175+
const confirm = within(dialog).getByRole('button', { name: pack.actionConfirm.confirm });
176+
expect(confirm.className).toContain('bg-primary');
177+
expect(confirm.className).not.toContain('bg-destructive');
178+
});
179+
});

‎packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ vi.mock('sonner', () => {
5050

5151
import { toast } from 'sonner';
5252
import { useObjectActions } from '../useObjectActions';
53+
import type { ActionResult, ConfirmationHandler } from '@object-ui/core';
5354

5455
// Mirror ObjectView's real toastHandler: route the runner's post-execution
5556
// toast into the same sonner sink the handler uses directly.
@@ -208,3 +209,77 @@ describe('useObjectActions — package-owned permission set delete = reset copy'
208209
);
209210
});
210211
});
212+
213+
// objectui#11695 — the delete asks with the shared copy's title and Delete
214+
// label, flagged destructive, through the host's confirm handler, ONCE; the
215+
// runner's own confirm step is not asked a second time.
216+
describe('useObjectActions — the delete confirmation names what it deletes (objectui#11695)', () => {
217+
function setupCopy(
218+
dataSource: { delete: ReturnType<typeof vi.fn> },
219+
onConfirmSpy: ConfirmationHandler,
220+
) {
221+
return renderHook(() =>
222+
useObjectActions({
223+
objectName: 'crm_product',
224+
objectLabel: 'Product',
225+
dataSource,
226+
onConfirm: onConfirmSpy,
227+
onToast,
228+
}),
229+
);
230+
}
231+
232+
it('one record: asks once with the title, the Delete label and `destructive`, then deletes', async () => {
233+
const dataSource = { delete: vi.fn().mockResolvedValue(undefined) };
234+
const confirmSpy = vi.fn().mockResolvedValue(true);
235+
const { result } = setupCopy(dataSource, confirmSpy);
236+
237+
await act(async () => {
238+
await result.current.deleteRecord('p1', { id: 'p1', name: 'QA Widget 0' });
239+
});
240+
241+
expect(confirmSpy).toHaveBeenCalledTimes(1);
242+
expect(confirmSpy).toHaveBeenCalledWith('objectActions.deleteConfirm', {
243+
title: 'objectActions.deleteConfirmTitle',
244+
confirmText: 'objectActions.deleteConfirmButton',
245+
destructive: true,
246+
});
247+
expect(dataSource.delete).toHaveBeenCalledWith('crm_product', 'p1');
248+
});
249+
250+
it('a batch: asks ONCE for all of them with the counting title, then deletes each', async () => {
251+
const dataSource = { delete: vi.fn().mockResolvedValue(undefined) };
252+
const confirmSpy = vi.fn().mockResolvedValue(true);
253+
const { result } = setupCopy(dataSource, confirmSpy);
254+
255+
await act(async () => {
256+
await result.current.deleteRecords([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
257+
});
258+
259+
expect(confirmSpy).toHaveBeenCalledTimes(1);
260+
expect(confirmSpy).toHaveBeenCalledWith('console.objectView.bulkDeleteConfirm', {
261+
title: 'objectActions.bulkDeleteConfirmTitle',
262+
confirmText: 'objectActions.deleteConfirmButton',
263+
destructive: true,
264+
});
265+
expect(dataSource.delete).toHaveBeenCalledTimes(3);
266+
});
267+
268+
it('a cancel deletes nothing and returns what the runner\'s cancel returned', async () => {
269+
const dataSource = { delete: vi.fn().mockResolvedValue(undefined) };
270+
const confirmSpy = vi.fn().mockResolvedValue(false);
271+
const { result } = setupCopy(dataSource, confirmSpy);
272+
273+
let one: ActionResult | undefined;
274+
let many: ActionResult | undefined;
275+
await act(async () => {
276+
one = await result.current.deleteRecord('p1', { id: 'p1', name: 'QA Widget 0' });
277+
many = await result.current.deleteRecords([{ id: 'a' }, { id: 'b' }]);
278+
});
279+
280+
expect(confirmSpy).toHaveBeenCalledTimes(2);
281+
expect(dataSource.delete).not.toHaveBeenCalled();
282+
expect(one).toEqual({ success: false, error: 'Action cancelled by user' });
283+
expect(many).toEqual({ success: false, error: 'Action cancelled by user' });
284+
});
285+
});

0 commit comments

Comments
 (0)