Skip to content
Merged
24 changes: 24 additions & 0 deletions .changeset/11695-delete-confirm-names-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/core': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-view': minor
'@object-ui/i18n': minor
---

A record delete confirmation now names what it deletes and confirms with a destructive "Delete" button (objectui#11695).

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:

- 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;
- titles a selection by its size and the object label, for example `Delete 3 Product records?`;
- labels its confirm button "Delete" and paints it in the destructive button style.

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.

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:

- `@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`.
- `@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.
- `@object-ui/i18n`: new keys in all ten packs: `objectActions.deleteConfirmTitle`, the `objectActions.bulkDeleteConfirmTitle` count family, and `objectActions.deleteConfirmButton`.

Other confirmations are unchanged. They keep the `actionConfirm.*` title and "Continue" button and the primary style.
2 changes: 1 addition & 1 deletion content/docs/guide/console-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ await execute({
```

The `ActionRunner` supports:
- **Confirmation** — async `ConfirmationHandler` (default: `window.confirm`, override with Shadcn AlertDialog)
- **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.
- **Toast notifications** — `ToastHandler` for success/error messages
- **Custom handlers** — register domain-specific action types (e.g., `'create'`, `'delete'`, `'refresh'`)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11695 — the console list's Delete names the record it deletes and
* confirms with a destructive Delete, in `en` and in `zh`.
*
* The console's object list deletes through `useObjectActions`: a row's Delete
* calls `deleteRecord`, a selection's calls `deleteRecords`, and both ask
* through the console runtime's confirm handler, which opens the in-app
* `ActionConfirmDialog`. That dialog used to read "Confirm Action" / "Continue"
* with the plain question, in the primary button style, for every delete.
*
* ## What makes these pins discriminating
*
* - The chain is REAL from the hook to the pixels: `useObjectActions`, the
* shared `recordDelete` core, `ActionConfirmDialog` and the shipped packs
* under a real `I18nProvider`. The one stand-in is the confirm handler, and
* it is the console runtime's own body (`useConsoleActionRuntime`'s
* `confirmHandler`: open the dialog with the message and options, settle on
* the answer), mounted without the rest of that runtime.
* - The record carries TWO name-ish values: its declared `nameField`
* (`product_name`) and a `name`. The title must show the declared one, so a
* host that stopped handing `objectDef` through would read `SKU-1` and fail.
* - Strings are read from the packs by KEY and interpolated here, so the pin is
* that the dialog shows THESE keys with THIS record and label, not what the
* keys happen to say today. The named subject is asserted on its own too.
* - The destructive style is read off the confirm button's class, with a
* CONTROL: a confirmation that does not ask for it keeps the primary style,
* so the class check is shown able to fail.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import type { ConfirmationHandler } from '@object-ui/core';

vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));

import { useObjectActions } from '../useObjectActions';
import { ActionConfirmDialog, type ConfirmDialogState } from '../../views/ActionConfirmDialog';

type Pack = {
objectActions: {
deleteConfirm: string;
deleteConfirmTitle: string;
deleteConfirmButton: string;
bulkDeleteConfirmTitle_other: string;
};
actionConfirm: { title: string; confirm: string; cancel: string };
};
const PACKS = {
en: builtInLocales.en as unknown as Pack,
zh: builtInLocales.zh as unknown as Pack,
};

const fill = (template: string, vars: Record<string, string | number>) =>
template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(vars[k]));

const PRODUCT = {
name: 'crm_product',
label: 'Product',
nameField: 'product_name',
fields: { product_name: { type: 'text' }, name: { type: 'text' } },
};
const ROW = { id: 'p1', product_name: 'QA Widget 0', name: 'SKU-1' };
const BATCH = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];

function ListDeleteHarness({ dataSource }: { dataSource: { delete: ReturnType<typeof vi.fn> } }) {
const [state, setState] = React.useState<ConfirmDialogState>({ open: false, message: '' });
// `useConsoleActionRuntime`'s confirm handler, verbatim in shape.
const onConfirm = React.useCallback<ConfirmationHandler>(
(message, options) =>
new Promise<boolean>((resolve) => {
setState({ open: true, message, options, resolve });
}),
[],
);
const actions = useObjectActions({
objectName: PRODUCT.name,
objectLabel: PRODUCT.label,
objectDef: PRODUCT,
dataSource,
onConfirm,
});
return (
<>
<button onClick={() => void actions.deleteRecord(ROW.id, ROW)}>row-delete</button>
<button onClick={() => void actions.deleteRecords(BATCH)}>bulk-delete</button>
<button onClick={() => void onConfirm('Proceed?')}>plain-confirm</button>
<ActionConfirmDialog
state={state}
onOpenChange={(open) => {
if (!open) setState((s) => ({ ...s, open: false }));
}}
/>
</>
);
}

function mount(language: keyof typeof PACKS) {
const dataSource = { delete: vi.fn(async () => ({})), findOne: vi.fn() };
render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<MemoryRouter initialEntries={['/apps/crm/crm_product']}>
<ListDeleteHarness dataSource={dataSource} />
</MemoryRouter>
</I18nProvider>,
);
return dataSource;
}

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe.each(['en', 'zh'] as const)('console list Delete in %s (objectui#11695)', (language) => {
const pack = PACKS[language];

it('a row names the record by its declared name field and the object label, with a destructive Delete', async () => {
const dataSource = mount(language);
fireEvent.click(screen.getByText('row-delete'));

const dialog = await screen.findByRole('alertdialog');
const title = fill(pack.objectActions.deleteConfirmTitle, { label: 'Product', name: 'QA Widget 0' });
await waitFor(() => expect(within(dialog).getByRole('heading')).toHaveTextContent(title));
// The named subject itself, independent of the template's words.
expect(within(dialog).getByRole('heading')).toHaveTextContent('QA Widget 0');
expect(within(dialog).getByRole('heading')).not.toHaveTextContent(pack.actionConfirm.title);
expect(within(dialog).getByText(pack.objectActions.deleteConfirm)).toBeInTheDocument();

const confirm = within(dialog).getByRole('button', { name: pack.objectActions.deleteConfirmButton });
expect(confirm.className).toContain('bg-destructive');
expect(within(dialog).queryByRole('button', { name: pack.actionConfirm.confirm })).toBeNull();
expect(dataSource.delete).not.toHaveBeenCalled();

fireEvent.click(confirm);
await waitFor(() => expect(dataSource.delete).toHaveBeenCalledWith('crm_product', 'p1'));
});

it('a selection counts the batch in the title, with the same destructive Delete', async () => {
const dataSource = mount(language);
fireEvent.click(screen.getByText('bulk-delete'));

const dialog = await screen.findByRole('alertdialog');
const title = fill(pack.objectActions.bulkDeleteConfirmTitle_other, { count: 3, label: 'Product' });
await waitFor(() => expect(within(dialog).getByRole('heading')).toHaveTextContent(title));
const confirm = within(dialog).getByRole('button', { name: pack.objectActions.deleteConfirmButton });
expect(confirm.className).toContain('bg-destructive');

fireEvent.click(confirm);
await waitFor(() => expect(dataSource.delete).toHaveBeenCalledTimes(3));
});

it('control: a confirmation that does not ask for `destructive` keeps the primary confirm', async () => {
mount(language);
fireEvent.click(screen.getByText('plain-confirm'));

const dialog = await screen.findByRole('alertdialog');
await waitFor(() =>
expect(within(dialog).getByRole('button', { name: pack.actionConfirm.confirm })).toBeInTheDocument(),
);
const confirm = within(dialog).getByRole('button', { name: pack.actionConfirm.confirm });
expect(confirm.className).toContain('bg-primary');
expect(confirm.className).not.toContain('bg-destructive');
});
});
75 changes: 75 additions & 0 deletions packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock('sonner', () => {

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

// Mirror ObjectView's real toastHandler: route the runner's post-execution
// toast into the same sonner sink the handler uses directly.
Expand Down Expand Up @@ -208,3 +209,77 @@ describe('useObjectActions — package-owned permission set delete = reset copy'
);
});
});

// objectui#11695 — the delete asks with the shared copy's title and Delete
// label, flagged destructive, through the host's confirm handler, ONCE; the
// runner's own confirm step is not asked a second time.
describe('useObjectActions — the delete confirmation names what it deletes (objectui#11695)', () => {
function setupCopy(
dataSource: { delete: ReturnType<typeof vi.fn> },
onConfirmSpy: ConfirmationHandler,
) {
return renderHook(() =>
useObjectActions({
objectName: 'crm_product',
objectLabel: 'Product',
dataSource,
onConfirm: onConfirmSpy,
onToast,
}),
);
}

it('one record: asks once with the title, the Delete label and `destructive`, then deletes', async () => {
const dataSource = { delete: vi.fn().mockResolvedValue(undefined) };
const confirmSpy = vi.fn().mockResolvedValue(true);
const { result } = setupCopy(dataSource, confirmSpy);

await act(async () => {
await result.current.deleteRecord('p1', { id: 'p1', name: 'QA Widget 0' });
});

expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(confirmSpy).toHaveBeenCalledWith('objectActions.deleteConfirm', {
title: 'objectActions.deleteConfirmTitle',
confirmText: 'objectActions.deleteConfirmButton',
destructive: true,
});
expect(dataSource.delete).toHaveBeenCalledWith('crm_product', 'p1');
});

it('a batch: asks ONCE for all of them with the counting title, then deletes each', async () => {
const dataSource = { delete: vi.fn().mockResolvedValue(undefined) };
const confirmSpy = vi.fn().mockResolvedValue(true);
const { result } = setupCopy(dataSource, confirmSpy);

await act(async () => {
await result.current.deleteRecords([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
});

expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(confirmSpy).toHaveBeenCalledWith('console.objectView.bulkDeleteConfirm', {
title: 'objectActions.bulkDeleteConfirmTitle',
confirmText: 'objectActions.deleteConfirmButton',
destructive: true,
});
expect(dataSource.delete).toHaveBeenCalledTimes(3);
});

it('a cancel deletes nothing and returns what the runner\'s cancel returned', async () => {
const dataSource = { delete: vi.fn().mockResolvedValue(undefined) };
const confirmSpy = vi.fn().mockResolvedValue(false);
const { result } = setupCopy(dataSource, confirmSpy);

let one: ActionResult | undefined;
let many: ActionResult | undefined;
await act(async () => {
one = await result.current.deleteRecord('p1', { id: 'p1', name: 'QA Widget 0' });
many = await result.current.deleteRecords([{ id: 'a' }, { id: 'b' }]);
});

expect(confirmSpy).toHaveBeenCalledTimes(2);
expect(dataSource.delete).not.toHaveBeenCalled();
expect(one).toEqual({ success: false, error: 'Action cancelled by user' });
expect(many).toEqual({ success: false, error: 'Action cancelled by user' });
});
});
Loading
Loading