diff --git a/.changeset/11695-delete-confirm-names-record.md b/.changeset/11695-delete-confirm-names-record.md new file mode 100644 index 0000000000..7bc0b0e041 --- /dev/null +++ b/.changeset/11695-delete-confirm-names-record.md @@ -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. diff --git a/content/docs/guide/console-architecture.md b/content/docs/guide/console-architecture.md index b0db5c8b11..adcc965179 100644 --- a/content/docs/guide/console-architecture.md +++ b/content/docs/guide/console-architecture.md @@ -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'`) diff --git a/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx b/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx new file mode 100644 index 0000000000..a33f07267c --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx @@ -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) => + 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 } }) { + const [state, setState] = React.useState({ open: false, message: '' }); + // `useConsoleActionRuntime`'s confirm handler, verbatim in shape. + const onConfirm = React.useCallback( + (message, options) => + new Promise((resolve) => { + setState({ open: true, message, options, resolve }); + }), + [], + ); + const actions = useObjectActions({ + objectName: PRODUCT.name, + objectLabel: PRODUCT.label, + objectDef: PRODUCT, + dataSource, + onConfirm, + }); + return ( + <> + + + + { + if (!open) setState((s) => ({ ...s, open: false })); + }} + /> + + ); +} + +function mount(language: keyof typeof PACKS) { + const dataSource = { delete: vi.fn(async () => ({})), findOne: vi.fn() }; + render( + + + + + , + ); + 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'); + }); +}); diff --git a/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx b/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx index dabff765ce..49ab6135c0 100644 --- a/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx @@ -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. @@ -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 }, + 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' }); + }); +}); diff --git a/packages/app-shell/src/hooks/useObjectActions.ts b/packages/app-shell/src/hooks/useObjectActions.ts index 82932b2bfc..1fff9afccb 100644 --- a/packages/app-shell/src/hooks/useObjectActions.ts +++ b/packages/app-shell/src/hooks/useObjectActions.ts @@ -16,16 +16,30 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useActionRunner } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; import { toast } from 'sonner'; -import { recordDelete, type ActionDef, type ActionResult } from '@object-ui/core'; +import { + recordDelete, + type ActionDef, + type ActionResult, + type ConfirmationHandler, + type RecordDeleteConfirmCopy, +} from '@object-ui/core'; + +/** The browser's native box — the fallback when the host passes no `onConfirm`. */ +const nativeConfirm: ConfirmationHandler = async (message) => window.confirm(message); interface ObjectActionConfig { objectName: string; objectLabel?: string; + /** + * The object definition, so the delete confirmation names the record by its + * declared name field (ADR-0079) rather than a guessed record key. + */ + objectDef?: unknown; dataSource: any; onEdit?: (record: any) => void; onRefresh?: () => void; /** Optional shadcn-style confirm handler — falls back to window.confirm */ - onConfirm?: (message: string, options?: { title?: string; confirmText?: string; cancelText?: string }) => Promise; + onConfirm?: ConfirmationHandler; /** Optional toast handler — falls back to sonner */ onToast?: (message: string, options?: { type?: string }) => void; } @@ -41,6 +55,8 @@ interface ObjectActions { * delete (ADR-0094) without an extra fetch. */ deleteRecord: (recordId: string, record?: Record) => Promise; + /** Delete several records, confirmed once for the batch. */ + deleteRecords: (records: Record[]) => Promise; /** Navigate to a view */ navigateToView: (viewId: string) => void; /** Navigate to a record detail */ @@ -54,6 +70,7 @@ interface ObjectActions { export function useObjectActions({ objectName, objectLabel, + objectDef, dataSource, onEdit, onRefresh, @@ -119,19 +136,55 @@ export function useObjectActions({ onEdit?.(null); }, [onEdit]); + // objectui#11695 — a delete asks with the shared core's WHOLE copy: a title + // that names the record (or counts the batch), the body, and a "Delete" + // button painted destructive. The runner's own confirm step hands the + // handler one argument — the `confirmText` body — so it can carry neither the + // title nor the button; the question is therefore asked here, through the + // same handler, and the delete then runs through the runner with no + // `confirmText`, so nothing asks twice. A cancel returns what the runner's + // cancel returned. + const label = objectLabel || objectName; + const confirmDelete = useCallback( + async (copy: RecordDeleteConfirmCopy): Promise => + (onConfirm ?? nativeConfirm)(copy.message, { + title: copy.title, + confirmText: copy.confirmText, + destructive: true, + }), + [onConfirm], + ); + const deleteRecord = useCallback( - async (recordId: string, record?: Record) => { - // The question comes from the shared record-delete core (objectui#10383): - // for a package-owned permission set it is the honest RESET question + async (recordId: string, record?: Record): Promise => { + // The copy comes from the shared record-delete core (objectui#10383): for + // a package-owned permission set the body is the honest RESET question // (ADR-0094), not a promise of an irreversible delete the user can see - // doesn't happen (the row stays). + // doesn't happen (the row stays). With only an id in hand the title + // names the record by the resolver's `Record #id` floor. + const copy = recordDelete.confirmCopy( + { objectName, t, label, objectDef }, + { record: record ?? { id: recordId } }, + ); + if (!(await confirmDelete(copy))) return { success: false, error: 'Action cancelled by user' }; return execute({ type: 'delete', - confirmText: recordDelete.confirmText({ objectName, t }, record), params: record ? { recordId, record } : { recordId }, }); }, - [execute, t, objectName], + [execute, t, objectName, label, objectDef, confirmDelete], + ); + + const deleteRecords = useCallback( + async (records: Record[]): Promise => { + const copy = recordDelete.confirmCopy( + { objectName, t, label, objectDef }, + { count: records.length }, + ); + if (!(await confirmDelete(copy))) return { success: false, error: 'Action cancelled by user' }; + return execute({ type: 'delete', params: { records } }); + }, + [execute, t, objectName, label, objectDef, confirmDelete], ); const navigateToView = useCallback( @@ -152,6 +205,7 @@ export function useObjectActions({ execute, create, deleteRecord, + deleteRecords, navigateToView, navigateToRecord, loading, diff --git a/packages/app-shell/src/views/ActionConfirmDialog.tsx b/packages/app-shell/src/views/ActionConfirmDialog.tsx index 2a908877a1..b20c806604 100644 --- a/packages/app-shell/src/views/ActionConfirmDialog.tsx +++ b/packages/app-shell/src/views/ActionConfirmDialog.tsx @@ -3,8 +3,17 @@ * * Uses Shadcn AlertDialog to replace window.confirm with a styled, accessible * confirmation dialog. Renders only when state.open is true. + * + * `options.destructive` (objectui#11695) paints the confirm button in the + * destructive button style — the record delete asks with it. It is a className + * override on `AlertDialogAction`, resolved by `cn()`'s tailwind-merge over the + * primitive's baked-in default, because `packages/components/src/ui/**` is a + * No-Touch zone (AGENTS.md #7); the `alert-dialog` renderer's `actionVariant` + * (objectui#8978) uses the same override. Without the flag the button keeps + * exactly the class it had. */ +import type { ConfirmationHandler } from '@object-ui/core'; import { AlertDialog, AlertDialogAction, @@ -14,13 +23,15 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + buttonVariants, } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; export interface ConfirmDialogState { open: boolean; message: string; - options?: { title?: string; confirmText?: string; cancelText?: string }; + /** The published handler's own options bag — not a re-spelled copy of it. */ + options?: Parameters[1]; resolve?: (value: boolean) => void; } @@ -54,7 +65,10 @@ export function ActionConfirmDialog({ state, onOpenChange }: ActionConfirmDialog {state.options?.cancelText || t('actionConfirm.cancel')} - + {state.options?.confirmText || t('actionConfirm.confirm')} diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index aff68811e2..6cf1bcae18 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -2628,7 +2628,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // and Sonner toast instead of native window.confirm. const actions = useObjectActions({ objectName: objectDef.name, - objectLabel: objectDef.label, + // The label this page shows (its header and breadcrumb read the same + // `objectLabel`), so the delete confirmation that names the record + // (objectui#11695) and the delete toasts read it in the session's + // language; with no translation bundle it is `objectDef.label`. + objectLabel: objectLabel(objectDef), + objectDef, dataSource, onEdit, onRefresh: refreshData, @@ -3523,17 +3528,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co onBulkDelete={(records: any[]) => { const valid = records.filter((r: any) => r?.id != null); if (valid.length === 0) return; - // Route through actions.execute so the shared AlertDialog - // confirms once for the whole batch and the existing - // delete handler (now batch-aware) handles refresh + toast. - actions.execute({ - type: 'delete', - confirmText: t('console.objectView.bulkDeleteConfirm', { - count: valid.length, - defaultValue: `Delete ${valid.length} selected records? This cannot be undone.`, - }), - params: { records: valid }, - }); + // `deleteRecords` confirms once for the whole batch, with the + // shared copy that counts it (objectui#11695), and the + // batch-aware delete handler handles refresh + toast. + actions.deleteRecords(valid); }} onRowClick={(record: any, event?: any) => { handleRowClick(record, event); diff --git a/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx b/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx index 7d7c16559b..95c86a9da3 100644 --- a/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx @@ -26,10 +26,12 @@ * - `window.confirm` is installed as a spy that ANSWERS YES. Against the * native-box code that answer deletes at once with no dialog, so "the dialog * is open and nothing is deleted yet" is red there, not vacuously green. - * - The strings are read from the `en` pack by KEY, not written out: the pin - * is that the dialog carries the page's own `detail.deleteConfirmation` - * question and the dialog's default `actionConfirm.*` buttons (the same - * ones the list view's delete shows), not what those keys say today. + * - The strings are read from the packs by KEY, not written out: the pin is + * that the dialog carries the list's delete copy (objectui#11695 — one + * shared `recordDelete.confirmCopy`): a title naming this record and its + * object, the list's question, and the destructive Delete confirm (the + * same ones the list view's delete shows), not what those keys say today. + * It is pinned in `en` and in `zh`. * - Cancel is judged on three observables — no delete call, no success toast, * and the router location unchanged — because "nothing happened" is also * what a click that never reached the handler looks like; the confirm case @@ -85,11 +87,17 @@ const OBJECT_NAME = 'crm_call'; const RECORD_ID = 'rec-call-1'; const START_PATH = `/apps/crm/${OBJECT_NAME}/${RECORD_ID}`; -/** The `en` pack, read by key — see the header on why nothing is written out. */ -const EN = builtInLocales.en as unknown as { - detail: { delete: string; deleteConfirmation: string }; - actionConfirm: { confirm: string; cancel: string }; +type Pack = { + detail: { delete: string; moreActions: string }; + objectActions: { deleteConfirm: string; deleteConfirmTitle: string; deleteConfirmButton: string }; + actionConfirm: { title: string; confirm: string; cancel: string }; }; +/** The packs, read by key — see the header on why nothing is written out. */ +const EN = builtInLocales.en as unknown as Pack; +const ZH = builtInLocales.zh as unknown as Pack; + +const fill = (template: string, vars: Record) => + template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => vars[k]); const OBJECTS = [ { @@ -138,13 +146,13 @@ function currentPath(): string { } /** Mount the real record page and wait for its header toolbar. */ -async function mountRecordPage() { +async function mountRecordPage(language: 'en' | 'zh' = 'en') { const dataSource = makeDataSource(); - // Under a real `I18nProvider` in `en`, as the console mounts it: the dialog's - // own title and buttons are pack keys with no inline default, so without a + // Under a real `I18nProvider`, as the console mounts it: the dialog's own + // title and buttons are pack keys with no inline default, so without a // provider they would render as the bare key names. render( - + expect(screen.getByRole('menu')).toBeInTheDocument()); const item = screen .getAllByRole('menuitem') - .find((el) => (el.textContent ?? '').trim() === EN.detail.delete); + .find((el) => (el.textContent ?? '').trim() === pack.detail.delete); expect(item).toBeTruthy(); fireEvent.click(item!); } @@ -213,9 +221,16 @@ describe('record page Delete asks through the in-app confirm dialog (objectui#11 await chooseHeaderDelete(); const dialog = await screen.findByRole('alertdialog'); - expect(within(dialog).getByText(EN.detail.deleteConfirmation)).toBeInTheDocument(); - // The list view's delete shows the dialog's default buttons; so does this. - expect(within(dialog).getByRole('button', { name: EN.actionConfirm.confirm })).toBeInTheDocument(); + // objectui#11695 — the list's copy: the title names this record and its + // object, the body is the list's question, the confirm is a destructive + // Delete (never the generic "Confirm Action" / "Continue"). + expect(within(dialog).getByRole('heading')).toHaveTextContent( + fill(EN.objectActions.deleteConfirmTitle, { label: 'Call', name: 'Intro call' }), + ); + expect(within(dialog).getByText(EN.objectActions.deleteConfirm)).toBeInTheDocument(); + const confirm = within(dialog).getByRole('button', { name: EN.objectActions.deleteConfirmButton }); + expect(confirm.className).toContain('bg-destructive'); + expect(within(dialog).queryByRole('button', { name: EN.actionConfirm.confirm })).toBeNull(); expect(within(dialog).getByRole('button', { name: EN.actionConfirm.cancel })).toBeInTheDocument(); expect(nativeConfirm).not.toHaveBeenCalled(); @@ -229,7 +244,7 @@ describe('record page Delete asks through the in-app confirm dialog (objectui#11 await chooseHeaderDelete(); const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByRole('button', { name: EN.actionConfirm.confirm })); + fireEvent.click(within(dialog).getByRole('button', { name: EN.objectActions.deleteConfirmButton })); await waitFor(() => expect(dataSource.delete).toHaveBeenCalledTimes(1)); expect(dataSource.delete).toHaveBeenCalledWith(OBJECT_NAME, RECORD_ID); @@ -257,3 +272,22 @@ describe('record page Delete asks through the in-app confirm dialog (objectui#11 expect(nativeConfirm).not.toHaveBeenCalled(); }); }); + +describe('record page Delete names the record in zh too (objectui#11695)', () => { + it('a zh session shows the zh title naming the record and the zh destructive Delete', async () => { + const dataSource = await mountRecordPage('zh'); + await chooseHeaderDelete(ZH); + + const dialog = await screen.findByRole('alertdialog'); + const title = fill(ZH.objectActions.deleteConfirmTitle, { label: 'Call', name: 'Intro call' }); + // The zh template is not the en one, so this reads the zh pack, not a fallback. + expect(title).not.toBe(fill(EN.objectActions.deleteConfirmTitle, { label: 'Call', name: 'Intro call' })); + expect(within(dialog).getByRole('heading')).toHaveTextContent(title); + expect(within(dialog).getByText(ZH.objectActions.deleteConfirm)).toBeInTheDocument(); + const confirm = within(dialog).getByRole('button', { name: ZH.objectActions.deleteConfirmButton }); + expect(confirm.className).toContain('bg-destructive'); + + fireEvent.click(confirm); + await waitFor(() => expect(dataSource.delete).toHaveBeenCalledWith(OBJECT_NAME, RECORD_ID)); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 2b31abcc2d..38463ba021 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -16,7 +16,7 @@ import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale } from '@object-ui/i18n'; import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged, useRowPredicate } from '@object-ui/react'; -import { buildExpandFields, captureUpdateUndoData, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields } from '@object-ui/core'; +import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration'; import { Database, ChevronLeft } from 'lucide-react'; @@ -2614,17 +2614,35 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // ActionDef is byte-identical to the pre-#4213 one. ...(deleteDisabledByPredicate ? { disabled: true } : null), onClick: async () => { - const msg = t('detail.deleteConfirmation', { - defaultValue: 'Are you sure you want to delete this record?', - }); + // objectui#11695 — the list's delete copy, from the one shared core + // (`recordDelete.confirmCopy`): the title names this record by the + // ADR-0079 resolver over the READABLE row the header title reads + // (a denied name field reads as absent here too) plus the object + // label, the body is the list's question — ADR-0094's reset question + // for a package-owned permission set — and the confirm button is + // "Delete", painted destructive. + const copy = recordDelete.confirmCopy( + { + objectName: objectName!, + t, + label: objectLabel({ name: objectName!, label: objectDef?.label || objectName! }), + objectDef, + }, + { record: (readablePageRecord as Record | null | undefined) ?? { id: pureRecordId } }, + ); // objectui#11001 — asked through this page's own confirm runtime, // the in-app `ActionConfirmDialog` the list view's delete asks // through too, never the browser's native `window.confirm` (which // cannot be themed, and which headless automation dismisses, so the - // button reads as dead). ONE argument, like the runner's call: the - // dialog's title and buttons are its defaults, as on the list view. - // A cancel settles `false` and leaves the record and the page alone. - if (!(await confirmHandler(msg))) return; + // button reads as dead). A cancel settles `false` and leaves the + // record and the page alone. + if ( + !(await confirmHandler(copy.message, { + title: copy.title, + confirmText: copy.confirmText, + destructive: true, + })) + ) return; try { await dataSource.delete(objectName!, pureRecordId!); toast.success(t('detail.deleted', { defaultValue: 'Record deleted' })); diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index fe4a9ce11e..dff5dc2bbc 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -528,6 +528,11 @@ export type ActionRunnerHandler = ( * `.cancel`), and `app-shell`'s `ActionConfirmDialog.tsx` renders them in * place of its generic `actionConfirm.*` fallbacks. Line numbers are left out * on purpose: #5205's pointer had already drifted by the time it was executed. + * The record delete is a second producer (objectui#11695): `app-shell`'s + * `useObjectActions` (the list's row and bulk Delete) and `RecordDetailView` + * (the record page's Delete) hand it `recordDelete.confirmCopy`'s title and + * confirm label with `destructive: true`, which paints the confirm button in + * the destructive button style. * * So re-measure across the repo, not this package: grep for two-argument * calls of `ConfirmationHandler`-typed values (`confirmHandler(`, `onConfirm(`) @@ -541,6 +546,8 @@ export type ConfirmationHandler = (message: string, options?: { title?: string; confirmText?: string; cancelText?: string; + /** The confirmed action destroys data: paint the confirm button destructive. */ + destructive?: boolean; }) => Promise; /** diff --git a/packages/core/src/actions/__tests__/recordDelete.test.ts b/packages/core/src/actions/__tests__/recordDelete.test.ts index ed46faeaa6..4791fc4611 100644 --- a/packages/core/src/actions/__tests__/recordDelete.test.ts +++ b/packages/core/src/actions/__tests__/recordDelete.test.ts @@ -80,6 +80,69 @@ describe('recordDelete.confirmText', () => { }); }); +/** + * objectui#11695 — the delete confirmation names what it deletes. The title + * names ONE record by the ADR-0079 resolver (reading the object's declared + * `nameField` when the host hands the definition) plus the object label, or + * counts a batch; the confirm button is the delete verb, never the generic + * "Continue"; the body is `confirmText`'s question (the reset question for a + * package-owned permission set). + */ +describe('recordDelete.confirmCopy (objectui#11695)', () => { + const PRODUCT = { + name: 'crm_product', + label: 'Product', + nameField: 'product_name', + fields: { product_name: { type: 'text' }, name: { type: 'text' } }, + }; + + it('names ONE record by its declared name field and the object label, and confirms with the delete verb', () => { + const copy = recordDelete.confirmCopy( + { objectName: 'crm_product', t, label: 'Product', objectDef: PRODUCT }, + { record: { id: 'p1', product_name: 'QA Widget 0', name: 'SKU-1' } }, + ); + expect(copy).toEqual({ + title: 'objectActions.deleteConfirmTitle {"label":"Product","name":"QA Widget 0"}', + message: 'objectActions.deleteConfirm', + confirmText: 'objectActions.deleteConfirmButton', + }); + }); + + it('without the definition, the resolver reads the record itself (control: the name field above was READ)', () => { + const copy = recordDelete.confirmCopy( + { objectName: 'crm_product', t, label: 'Product' }, + { record: { id: 'p1', product_name: 'QA Widget 0', name: 'SKU-1' } }, + ); + expect(copy.title).toBe('objectActions.deleteConfirmTitle {"label":"Product","name":"SKU-1"}'); + }); + + it('a row with no name reads by the resolver floor, never a blank', () => { + const copy = recordDelete.confirmCopy( + { objectName: 'crm_product', t, label: 'Product', objectDef: PRODUCT }, + { record: { id: 'p9' } }, + ); + expect(copy.title).toBe('objectActions.deleteConfirmTitle {"label":"Product","name":"Record #p9"}'); + }); + + it('keeps the ADR-0094 reset question as the body for a package-owned permission set', () => { + const copy = recordDelete.confirmCopy( + { objectName: 'sys_permission_set', t, label: 'Permission Set' }, + { record: { id: 'ps1', name: 'Sales Rep', managed_by: 'package' } }, + ); + expect(copy.message).toBe('objectActions.resetPackageSetConfirm'); + expect(copy.title).toBe('objectActions.deleteConfirmTitle {"label":"Permission Set","name":"Sales Rep"}'); + }); + + it('counts a batch in the title and asks the batch question', () => { + const copy = recordDelete.confirmCopy({ objectName: 'crm_lead', t, label: 'Lead' }, { count: 3 }); + expect(copy).toEqual({ + title: 'objectActions.bulkDeleteConfirmTitle {"count":3,"label":"Lead"}', + message: 'console.objectView.bulkDeleteConfirm {"count":3}', + confirmText: 'objectActions.deleteConfirmButton', + }); + }); +}); + describe('recordDelete.run — one record', () => { it('deletes, refreshes, toasts success, and returns the silent success the runner expects', async () => { const { deps, dataSource, toast, onRefresh } = harness(); diff --git a/packages/core/src/actions/recordDelete.ts b/packages/core/src/actions/recordDelete.ts index bc25ece015..11aec05340 100644 --- a/packages/core/src/actions/recordDelete.ts +++ b/packages/core/src/actions/recordDelete.ts @@ -27,9 +27,20 @@ * `@object-ui/core` is the lowest package both hosts already depend on. The * host-specific pieces are INJECTED — the translator, the toast sink, the data * source and the refresh — so this package gains no i18n or toast dependency. - * The host keeps its own confirm UI (`app-shell` runs the question through the - * action runner's confirm handler; `plugin-view` renders it in its own - * AlertDialog); the QUESTION comes from {@link recordDelete}.confirmText. + * The host keeps its own confirm UI (`app-shell` hands the copy to the console's + * confirm handler; `plugin-view` renders it in its own AlertDialog); the COPY — + * title, body and confirm-button label — comes from + * {@link recordDelete}.confirmCopy, whose body for one record is + * {@link recordDelete}.confirmText. + * + * ## The confirmation names what it deletes (objectui#11695) + * + * A generic "Confirm Action" / "Continue" dialog never said WHICH record was + * about to go, nor that the click is a delete. The title names the record by + * the ONE ADR-0079 display-name resolver ({@link getRecordDisplayName}, the + * resolver the record header and lookups use), and the object label — or, for + * a batch, the count. The confirm button reads as the deletion it is; every + * host paints it in the destructive button style. * * ## ADR-0094 — a package-owned permission set is RESET, not deleted * @@ -57,6 +68,7 @@ */ import type { ActionResult } from './ActionRunner.js'; +import { getRecordDisplayName } from '../utils/record-title.js'; type Row = Record; @@ -93,6 +105,26 @@ interface RecordDeleteRequest { recordId?: unknown; } +/** + * The copy of a delete confirmation — everything a host's confirm dialog shows, + * from {@link recordDelete}.confirmCopy (objectui#11695). + */ +export interface RecordDeleteConfirmCopy { + /** The dialog title: names the record and its object, or the batch size. */ + title: string; + /** The dialog body: the plain delete question, or ADR-0094's reset question. */ + message: string; + /** The confirm button's label: a deletion verb, painted destructive by the host. */ + confirmText: string; +} + +/** + * What a delete confirmation is about: ONE record (its row — at least its + * `id`, so a nameless row still reads by the resolver's `Record #id` floor) or + * a batch (how many). + */ +export type RecordDeleteConfirmTarget = { record: Row | null | undefined } | { count: number }; + const PERMISSION_SET_OBJECT = 'sys_permission_set'; /** ADR-0094: is this permission-set row owned by an installed package? */ @@ -108,9 +140,10 @@ function isPackageOwned(row: unknown): boolean { */ export const recordDelete = Object.freeze({ /** - * The question to ask before deleting ONE record: the reset question for a - * package-owned permission set (ADR-0094), the plain delete question - * otherwise. Reads only the row it is handed — no lookup. + * The question to ask before deleting ONE record — the body of + * {@link recordDelete}.confirmCopy: the reset question for a package-owned + * permission set (ADR-0094), the plain delete question otherwise. Reads only + * the row it is handed — no lookup. */ confirmText(deps: Pick, record?: Row | null): string { const { objectName, t } = deps; @@ -123,6 +156,45 @@ export const recordDelete = Object.freeze({ : t('objectActions.deleteConfirm'); }, + /** + * The whole delete confirmation — title, body and confirm-button label — for + * ONE record or a batch (objectui#11695). Every host's delete dialog shows + * this copy, so the list's row Delete, its bulk Delete and the record page's + * Delete say the same thing. + * + * One record: the title names it — the object label plus the record's + * display name from {@link getRecordDisplayName}, reading `objectDef`'s + * declared name field when the host has the definition — and the body is + * {@link recordDelete}.confirmText (the ADR-0094 reset question for a + * package-owned permission set). A batch: the title counts it. The confirm + * button is the delete verb in both, which the host paints destructive. + */ + confirmCopy( + deps: Pick & { + /** The object definition, so the name follows its declared `nameField`. */ + objectDef?: unknown; + }, + target: RecordDeleteConfirmTarget, + ): RecordDeleteConfirmCopy { + const { t, label } = deps; + const confirmText = t('objectActions.deleteConfirmButton'); + if ('count' in target) { + return { + title: t('objectActions.bulkDeleteConfirmTitle', { count: target.count, label }), + message: t('console.objectView.bulkDeleteConfirm', { count: target.count }), + confirmText, + }; + } + return { + title: t('objectActions.deleteConfirmTitle', { + label, + name: getRecordDisplayName(deps.objectDef, target.record), + }), + message: recordDelete.confirmText(deps, target.record), + confirmText, + }; + }, + /** * Delete, refresh and report. Several records (`params.records`, more than * one with an `id`) are deleted one call each and settled together, then the diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 961ab5e557..061f0c24b4 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -306,7 +306,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // („{{label}}“) — one interpolated span, runtime data. // 70 once objectui#11445 made `search.resultsCount` a count family — its // `_one` and `_other` slots quote the query, and `resultsCountPlural` left. - expect(okSpans, 'correctly paired spans').toBe(70); + // 71 once objectui#11695 added `objectActions.deleteConfirmTitle`, the + // delete confirmation's title, which quotes the record's name + // („{{name}}“) — one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(71); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -345,7 +348,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `fields.grid.noItemsAddHint`, one more matched pair. 70 / 70 / 0 after // objectui#11445 made `search.resultsCount` a count family: `_one` and // `_other` carry one matched pair each and `resultsCountPlural` left. - expect({ open, close, rdq }).toEqual({ open: 70, close: 70, rdq: 0 }); + // 71 / 71 / 0 after objectui#11695 added `objectActions.deleteConfirmTitle`, + // one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 71, close: 71, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 00351a6a93..425545bff0 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2997,6 +2997,15 @@ const ar = { deleteFailed: "تعذر حذف {{label}}", noRecordId: "لم يتم توفير معرف سجل", deleteConfirm: "حذف هذا السجل؟", + deleteConfirmTitle: "حذف {{label}} \"{{name}}\"؟", + bulkDeleteConfirmTitle: "حذف {{count}} سجل(ات) {{label}}؟", + bulkDeleteConfirmTitle_zero: "حذف {{count}} من سجلات {{label}}؟", + bulkDeleteConfirmTitle_one: "حذف {{count}} من سجلات {{label}}؟", + bulkDeleteConfirmTitle_two: "حذف {{count}} من سجلات {{label}}؟", + bulkDeleteConfirmTitle_few: "حذف {{count}} من سجلات {{label}}؟", + bulkDeleteConfirmTitle_many: "حذف {{count}} من سجلات {{label}}؟", + bulkDeleteConfirmTitle_other: "حذف {{count}} من سجلات {{label}}؟", + deleteConfirmButton: "حذف", bulkDeleteSuccess: "تم حذف {{count}} سجل(ات) {{label}}", bulkDeleteSuccess_zero: "تم حذف {{count}} من سجلات {{label}}", bulkDeleteSuccess_one: "تم حذف {{count}} من سجلات {{label}}", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cc3cd60229..24e3aa2fb3 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2778,6 +2778,11 @@ const de = { deleteFailed: "{{label}} konnte nicht gelöscht werden", noRecordId: "Keine Datensatz-ID angegeben", deleteConfirm: "Möchten Sie diesen Datensatz wirklich löschen?", + deleteConfirmTitle: "{{label}} „{{name}}“ löschen?", + bulkDeleteConfirmTitle: "{{count}} {{label}}-Datensätze löschen?", + bulkDeleteConfirmTitle_one: "{{count}} {{label}}-Datensatz löschen?", + bulkDeleteConfirmTitle_other: "{{count}} {{label}}-Datensätze löschen?", + deleteConfirmButton: "Löschen", bulkDeleteSuccess: "{{count}} {{label}}-Datensätze gelöscht", bulkDeleteSuccess_one: "{{count}} {{label}}-Datensatz gelöscht", bulkDeleteSuccess_other: "{{count}} {{label}}-Datensätze gelöscht", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 3a7cf020ac..c901c6ade2 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3358,6 +3358,11 @@ const en = { deleteFailed: 'Failed to delete {{label}}', noRecordId: 'No record ID provided', deleteConfirm: 'Are you sure you want to delete this record?', + deleteConfirmTitle: 'Delete {{label}} "{{name}}"?', + bulkDeleteConfirmTitle: 'Delete {{count}} {{label}} records?', + bulkDeleteConfirmTitle_one: 'Delete {{count}} {{label}} record?', + bulkDeleteConfirmTitle_other: 'Delete {{count}} {{label}} records?', + deleteConfirmButton: 'Delete', resetPackageSetConfirm: 'This permission set ships with an installed package and cannot be removed. ' + 'Deleting resets it to the shipped baseline and discards your environment customization. Continue?', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index faa29d424c..ddf13d83cb 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2831,6 +2831,12 @@ const es = { deleteFailed: "No se pudo eliminar {{label}}", noRecordId: "No se proporcionó ID de registro", deleteConfirm: "¿Eliminar este registro?", + deleteConfirmTitle: "¿Eliminar {{label}} \"{{name}}\"?", + bulkDeleteConfirmTitle: "¿Eliminar {{count}} registros de {{label}}?", + bulkDeleteConfirmTitle_one: "¿Eliminar {{count}} registro de {{label}}?", + bulkDeleteConfirmTitle_many: "¿Eliminar {{count}} de registros de {{label}}?", + bulkDeleteConfirmTitle_other: "¿Eliminar {{count}} registros de {{label}}?", + deleteConfirmButton: "Eliminar", bulkDeleteSuccess: "{{count}} registros de {{label}} eliminados", bulkDeleteSuccess_one: "{{count}} registro de {{label}} eliminado", bulkDeleteSuccess_many: "{{count}} de registros de {{label}} eliminados", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d52ad37cd8..c68bb9415f 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2828,6 +2828,12 @@ const fr = { deleteFailed: "Impossible de supprimer {{label}}", noRecordId: "Aucun ID d'enregistrement fourni", deleteConfirm: "Supprimer cet enregistrement ?", + deleteConfirmTitle: "Supprimer {{label}} \"{{name}}\" ?", + bulkDeleteConfirmTitle: "Supprimer {{count}} enregistrement(s) {{label}} ?", + bulkDeleteConfirmTitle_one: "Supprimer {{count}} enregistrement {{label}} ?", + bulkDeleteConfirmTitle_many: "Supprimer {{count}} d'enregistrements {{label}} ?", + bulkDeleteConfirmTitle_other: "Supprimer {{count}} enregistrements {{label}} ?", + deleteConfirmButton: "Supprimer", bulkDeleteSuccess: "{{count}} enregistrement(s) {{label}} supprimé(s)", bulkDeleteSuccess_one: "{{count}} enregistrement {{label}} supprimé", bulkDeleteSuccess_many: "{{count}} d'enregistrements {{label}} supprimés", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 71cb9fee08..74feb520c7 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2780,6 +2780,11 @@ const ja = { deleteFailed: "{{label}} の削除に失敗しました", noRecordId: "レコードIDが指定されていません", deleteConfirm: "このレコードを削除してもよろしいですか?", + deleteConfirmTitle: "{{label}}「{{name}}」を削除しますか?", + bulkDeleteConfirmTitle: "{{count}} 件の {{label}} レコードを削除しますか?", + bulkDeleteConfirmTitle_one: "{{count}} 件の {{label}} レコードを削除しますか?", + bulkDeleteConfirmTitle_other: "{{count}} 件の {{label}} レコードを削除しますか?", + deleteConfirmButton: "削除", bulkDeleteSuccess: "{{count}} 件の {{label}} レコードを削除しました", bulkDeleteSuccess_one: "{{count}} 件の {{label}} レコードを削除しました", bulkDeleteSuccess_other: "{{count}} 件の {{label}} レコードを削除しました", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 859c6a657e..421e2a8814 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2777,6 +2777,11 @@ const ko = { deleteFailed: "{{label}} 삭제 실패", noRecordId: "레코드 ID가 없습니다", deleteConfirm: "이 레코드를 삭제하시겠습니까?", + deleteConfirmTitle: "{{label}} \"{{name}}\"을(를) 삭제하시겠습니까?", + bulkDeleteConfirmTitle: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", + bulkDeleteConfirmTitle_one: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", + bulkDeleteConfirmTitle_other: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", + deleteConfirmButton: "삭제", bulkDeleteSuccess: "{{count}}개의 {{label}} 레코드 삭제됨", bulkDeleteSuccess_one: "{{count}}개의 {{label}} 레코드 삭제됨", bulkDeleteSuccess_other: "{{count}}개의 {{label}} 레코드 삭제됨", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 70012a4e74..eff3797300 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2826,6 +2826,12 @@ const pt = { deleteFailed: "Não foi possível excluir {{label}}", noRecordId: "Nenhum ID de registro fornecido", deleteConfirm: "Excluir este registro?", + deleteConfirmTitle: "Excluir {{label}} \"{{name}}\"?", + bulkDeleteConfirmTitle: "Excluir {{count}} registros de {{label}}?", + bulkDeleteConfirmTitle_one: "Excluir {{count}} registro de {{label}}?", + bulkDeleteConfirmTitle_many: "Excluir {{count}} de registros de {{label}}?", + bulkDeleteConfirmTitle_other: "Excluir {{count}} registros de {{label}}?", + deleteConfirmButton: "Excluir", bulkDeleteSuccess: "{{count}} registros de {{label}} excluídos", bulkDeleteSuccess_one: "{{count}} registro de {{label}} excluído", bulkDeleteSuccess_many: "{{count}} de registros de {{label}} excluídos", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index ff2225e8f7..f209bf7c6e 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2900,6 +2900,13 @@ const ru = { deleteFailed: "Не удалось удалить {{label}}", noRecordId: "ID записи не указан", deleteConfirm: "Удалить эту запись?", + deleteConfirmTitle: "Удалить {{label}} \"{{name}}\"?", + bulkDeleteConfirmTitle: "Удалить {{count}} записей {{label}}?", + bulkDeleteConfirmTitle_one: "Удалить {{count}} запись {{label}}?", + bulkDeleteConfirmTitle_few: "Удалить {{count}} записи {{label}}?", + bulkDeleteConfirmTitle_many: "Удалить {{count}} записей {{label}}?", + bulkDeleteConfirmTitle_other: "Удалить {{count}} записи {{label}}?", + deleteConfirmButton: "Удалить", bulkDeleteSuccess: "{{count}} записей {{label}} удалено", bulkDeleteSuccess_one: "Удалена {{count}} запись {{label}}", bulkDeleteSuccess_few: "Удалено {{count}} записи {{label}}", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b4ad27b941..6e1319327a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2948,6 +2948,11 @@ const zh = { deleteFailed: '删除 {{label}} 失败', noRecordId: '未提供记录 ID', deleteConfirm: '确定要删除此记录吗?', + deleteConfirmTitle: '删除{{label}}“{{name}}”?', + bulkDeleteConfirmTitle: '删除 {{count}} 条 {{label}} 记录?', + bulkDeleteConfirmTitle_one: '删除 {{count}} 条 {{label}} 记录?', + bulkDeleteConfirmTitle_other: '删除 {{count}} 条 {{label}} 记录?', + deleteConfirmButton: '删除', resetPackageSetConfirm: '该权限集随已安装的软件包发布,无法删除。删除操作将丢弃环境定制并把它重置为发布基线。是否继续?', resetPackageSetSuccess: '权限集已重置为发布基线', diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 4b29146535..db6665eae1 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -63,6 +63,7 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + buttonVariants, toast, } from '@object-ui/components'; import { Plus } from 'lucide-react'; @@ -539,12 +540,17 @@ const VIEW_DEFAULT_TRANSLATIONS: Record = { // The `objectActions.*` rows are asked for by the shared `recordDelete` core // (`@object-ui/core`), which this view hands `tView`, so a provider-less host // reads them here; the ADR-0094 reset rows carry their own inline - // `defaultValue` there. `console.objectView.bulkDeleteConfirm` and the - // `actionConfirm.*` chrome are this host's dialog, in the console's + // `defaultValue` there. The dialog's copy is the core's too + // (`recordDelete.confirmCopy`, objectui#11695): the title, the + // `console.objectView.bulkDeleteConfirm` question and the Delete label; + // `actionConfirm.cancel` is this host's own chrome, in the console's // `ActionConfirmDialog` shape. - 'actionConfirm.title': 'Confirm Action', - 'actionConfirm.confirm': 'Continue', 'actionConfirm.cancel': 'Cancel', + 'objectActions.deleteConfirmTitle': 'Delete {{label}} "{{name}}"?', + 'objectActions.bulkDeleteConfirmTitle': 'Delete {{count}} {{label}} records?', + 'objectActions.bulkDeleteConfirmTitle_one': 'Delete {{count}} {{label}} record?', + 'objectActions.bulkDeleteConfirmTitle_other': 'Delete {{count}} {{label}} records?', + 'objectActions.deleteConfirmButton': 'Delete', 'objectActions.deleteConfirm': 'Are you sure you want to delete this record?', 'console.objectView.bulkDeleteConfirm': 'Delete {{count}} selected records? This cannot be undone.', // Count families (objectui#11445): `fallbackT` reads a family's `_one` / @@ -3114,12 +3120,24 @@ export const ObjectView: React.FC = ({ const toolbar = renderToolbar(); // The delete confirmation (objectui#10383) — this host's confirm UI, in the - // console's `ActionConfirmDialog` shape and keys (`actionConfirm.*` chrome, - // the question as the description). The one-row question and the delete - // itself come from the shared `recordDelete` core. Close flips `open` and KEEPS the request, so - // the description does not blank during the exit animation (the objectui#6034 - // lesson); the `open` guard on Continue is what stops a click during that - // animation from deleting twice. + // console's `ActionConfirmDialog` shape (`actionConfirm.cancel` chrome, the + // question as the description). Its copy — the title naming the record or + // counting the batch, the question, and the "Delete" confirm label — and the + // delete itself come from the shared `recordDelete` core, so this dialog + // says what the console's says (objectui#11695); the confirm button is + // painted destructive the way `ActionConfirmDialog` paints it. Close flips + // `open` and KEEPS the request, so the copy does not blank during the exit + // animation (the objectui#6034 lesson); the `open` guard on Delete is what + // stops a click during that animation from deleting twice. + const deleteLabel = (objectSchema?.label as string) || schema.objectName; + const deleteCopy = deleteRequest + ? recordDelete.confirmCopy( + { objectName: schema.objectName, t: tView, label: deleteLabel, objectDef: objectSchema }, + deleteRequest.bulk + ? { count: deleteRequest.records.length } + : { record: deleteRequest.records[0] }, + ) + : null; const deleteConfirmDialog = ( = ({ > - {tView('actionConfirm.title')} - - {deleteRequest?.bulk - ? tView('console.objectView.bulkDeleteConfirm', { count: deleteRequest.records.length }) - : recordDelete.confirmText( - { objectName: schema.objectName, t: tView }, - deleteRequest?.records[0], - )} - + {deleteCopy?.title} + {deleteCopy?.message} {tView('actionConfirm.cancel')} { if (!deleteRequest?.open) return; const { records, bulk } = deleteRequest; @@ -3149,7 +3161,7 @@ export const ObjectView: React.FC = ({ void recordDelete.run( { objectName: schema.objectName, - label: (objectSchema?.label as string) || schema.objectName, + label: deleteLabel, dataSource, t: tView, toast, @@ -3161,7 +3173,7 @@ export const ObjectView: React.FC = ({ ); }} > - {tView('actionConfirm.confirm')} + {deleteCopy?.confirmText} diff --git a/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx index 0fbb36d087..d652c55201 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx @@ -25,8 +25,10 @@ * The console's own list (`app-shell` `ObjectView` → `useObjectActions`) is * the behaviour matched, and every case below reads one of its steps: * - a row asks "Are you sure you want to delete this record?"; a selection - * asks "Delete N selected records? This cannot be undone." once; - * - Continue deletes through `dataSource.delete`, one call per record, and the + * asks "Delete N selected records? This cannot be undone." once; the + * title names the row (or counts the selection) and the confirm button is + * a destructive "Delete" (objectui#11695); + * - Delete deletes through `dataSource.delete`, one call per record, and the * row is gone after the refresh; Cancel deletes nothing; * - success toasts `{{label}} deleted successfully` / * `Deleted N {{label}} records`; a failure toasts `Failed to delete @@ -82,6 +84,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => { }); import { toast } from '@object-ui/components'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; import type { DataSource, ObjectViewSchema } from '@object-ui/types'; import { ObjectView } from '../ObjectView'; @@ -223,10 +227,15 @@ describe('objectui#10383 — row Delete on the registered object-view path', () const dialog = await confirmDialog(); expect(within(dialog).getByText('Are you sure you want to delete this record?')).toBeInTheDocument(); + // objectui#11695 — the title names the row and its object; the confirm is + // a destructive Delete, not the generic "Confirm Action" / "Continue". + expect(within(dialog).getByRole('heading')).toHaveTextContent('Delete Test object "Alice"?'); + expect(within(dialog).getByRole('button', { name: 'Delete' }).className).toContain('bg-destructive'); + expect(within(dialog).queryByRole('button', { name: 'Continue' })).toBeNull(); // Nothing is deleted before the user answers. expect(ds.delete).not.toHaveBeenCalled(); - await userEvent.click(within(dialog).getByRole('button', { name: 'Continue' })); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); await waitFor(() => expect(ds.delete).toHaveBeenCalledTimes(1)); expect(ds.delete).toHaveBeenCalledWith(OBJECT, 'r1'); @@ -257,7 +266,7 @@ describe('objectui#10383 — row Delete on the registered object-view path', () await settled(); await userEvent.click((await openRowMenu('Alice'))!); - await userEvent.click(within(await confirmDialog()).getByRole('button', { name: 'Continue' })); + await userEvent.click(within(await confirmDialog()).getByRole('button', { name: 'Delete' })); await waitFor(() => expect(errorSpy).toHaveBeenCalledTimes(1)); expect(ds.delete).toHaveBeenCalledWith(OBJECT, 'r1'); @@ -280,9 +289,11 @@ describe('objectui#10383 — bulk Delete on the registered object-view path', () expect( within(dialog).getByText('Delete 2 selected records? This cannot be undone.'), ).toBeInTheDocument(); + expect(within(dialog).getByRole('heading')).toHaveTextContent('Delete 2 Test object records?'); + expect(within(dialog).getByRole('button', { name: 'Delete' }).className).toContain('bg-destructive'); expect(ds.delete).not.toHaveBeenCalled(); - await userEvent.click(within(dialog).getByRole('button', { name: 'Continue' })); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); await waitFor(() => expect(ds.delete).toHaveBeenCalledTimes(2)); expect(ds.delete).toHaveBeenCalledWith(OBJECT, 'r1'); @@ -301,7 +312,7 @@ describe('objectui#10383 — bulk Delete on the registered object-view path', () selectRows(['Alice', 'Bob']); fireEvent.click(await screen.findByTestId('bulk-action-delete')); - await userEvent.click(within(await confirmDialog()).getByRole('button', { name: 'Continue' })); + await userEvent.click(within(await confirmDialog()).getByRole('button', { name: 'Delete' })); await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('1 deleted, 1 failed')); expect(ds.delete).toHaveBeenCalledTimes(2); @@ -414,8 +425,9 @@ describe('objectui#10383 — ADR-0094 reset copy on the registered object-view p 'Deleting resets it to the shipped baseline and discards your environment customization. Continue?', ), ).toBeInTheDocument(); + expect(within(dialog).getByRole('heading')).toHaveTextContent('Delete Permission Set "Sales Rep"?'); - await userEvent.click(within(dialog).getByRole('button', { name: 'Continue' })); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); await waitFor(() => expect(ds.delete).toHaveBeenCalledWith(PERMISSION_SET, 'ps1')); await waitFor(() => @@ -434,10 +446,58 @@ describe('objectui#10383 — ADR-0094 reset copy on the registered object-view p within(dialog).getByText('Are you sure you want to delete this record?'), ).toBeInTheDocument(); - await userEvent.click(within(dialog).getByRole('button', { name: 'Continue' })); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); await waitFor(() => expect(successSpy).toHaveBeenCalledWith('Permission Set deleted successfully'), ); }); }); + +/** + * objectui#11695 in a zh session: under a real `I18nProvider` this host's + * translator is the session's, so the title and the Delete label come from the + * zh pack — read here by KEY, so the pin is the keys and the named row, not + * today's wording. + */ +describe('objectui#11695 — the row Delete names the row in zh', () => { + const ZH = builtInLocales.zh as unknown as { + objectActions: { deleteConfirmTitle: string; deleteConfirmButton: string }; + }; + + it('shows the zh title naming the row and the zh destructive Delete, and deletes on it', async () => { + const ds = makeDataSource(); + render( + + + + + + + , + ); + await settled(); + + const deleteItem = await openRowMenu('Alice'); + expect(deleteItem, 'the row kebab offers no Delete — the harness is not reaching the subject').not.toBeNull(); + await userEvent.click(deleteItem!); + + const dialog = await confirmDialog(); + const title = ZH.objectActions.deleteConfirmTitle + .replace('{{label}}', 'Test object') + .replace('{{name}}', 'Alice'); + await waitFor(() => expect(within(dialog).getByRole('heading')).toHaveTextContent(title)); + const confirm = within(dialog).getByRole('button', { name: ZH.objectActions.deleteConfirmButton }); + expect(confirm.className).toContain('bg-destructive'); + + await userEvent.click(confirm); + await waitFor(() => expect(ds.delete).toHaveBeenCalledWith(OBJECT, 'r1')); + }); +});