diff --git a/.changeset/27823-record-delete-permanent-named.md b/.changeset/27823-record-delete-permanent-named.md new file mode 100644 index 00000000000..4d26b1dc226 --- /dev/null +++ b/.changeset/27823-record-delete-permanent-named.md @@ -0,0 +1,17 @@ +--- +'@object-ui/core': patch +'@object-ui/i18n': patch +'@object-ui/plugin-view': patch +'@object-ui/plugin-detail': patch +--- + +fix(console): a record delete says it cannot be undone, and the success toast names the record + +Clause-②: yes (widening) +Metadata-protocol: none + +A record delete is permanent (there is no recycle bin), yet the one-record confirm only asked "Are you sure you want to delete this record?" and the success toast said "Record deleted" (record page) or "Task deleted successfully" (list) without saying which record went. + +- **The question.** `objectActions.deleteConfirm` (the list row, the record page and the object-view grid Delete) and plugin-detail's `detail.deleteConfirmation` now end with the pack's own "This cannot be undone." sentence, the one the batch question already carried, in all ten locale packs. +- **The toast.** `recordDelete.successText` (`@object-ui/core`) names the deleted record by the same display-name resolver the confirm title reads: `Task "Website Relaunch" deleted`. A record with nothing nameable keeps `{{label}} deleted successfully`. The record page, the console list and the object-view grid all use it; `recordDelete.run` takes an optional `objectDef` so the name follows the object's declared `nameField`. A batch delete keeps its count. +- `@object-ui/i18n` gains `objectActions.deletedNamed` in all ten packs. There is no Undo: there is nothing to restore from. diff --git a/ui/packages/app-shell/src/hooks/useObjectActions.ts b/ui/packages/app-shell/src/hooks/useObjectActions.ts index 6cb842669fa..165348cc431 100644 --- a/ui/packages/app-shell/src/hooks/useObjectActions.ts +++ b/ui/packages/app-shell/src/hooks/useObjectActions.ts @@ -109,7 +109,7 @@ export function useObjectActions({ // success), so the runner's post-execution hook adds no second toast. runner.registerHandler('delete', (action: any) => recordDelete.run( - { objectName, label: objectLabel || objectName, dataSource, t, toast, onRefresh }, + { objectName, label: objectLabel || objectName, objectDef, dataSource, t, toast, onRefresh }, action, ), ); @@ -128,7 +128,7 @@ export function useObjectActions({ onRefresh?.(); return { success: true, reload: true }; }); - }, [runner, objectName, dataSource, onEdit, onRefresh, navigate, baseUrl, t, objectLabel]); + }, [runner, objectName, dataSource, onEdit, onRefresh, navigate, baseUrl, t, objectLabel, objectDef]); const create = useCallback(() => { onEdit?.(null); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx index 95c86a9da37..54944f7b2a6 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx @@ -89,7 +89,14 @@ const START_PATH = `/apps/crm/${OBJECT_NAME}/${RECORD_ID}`; type Pack = { detail: { delete: string; moreActions: string }; - objectActions: { deleteConfirm: string; deleteConfirmTitle: string; deleteConfirmButton: string }; + objectActions: { + deleteConfirm: string; + deleteConfirmTitle: string; + deleteConfirmButton: string; + deletedNamed: string; + deleteSuccess: string; + }; + console: { objectView: { bulkDeleteConfirm: string } }; actionConfirm: { title: string; confirm: string; cancel: string }; }; /** The packs, read by key — see the header on why nothing is written out. */ @@ -228,6 +235,11 @@ describe('record page Delete asks through the in-app confirm dialog (objectui#11 fill(EN.objectActions.deleteConfirmTitle, { label: 'Call', name: 'Intro call' }), ); expect(within(dialog).getByText(EN.objectActions.deleteConfirm)).toBeInTheDocument(); + // objectstack-ai/objectstack#27823 — the question says the delete is + // permanent, in the batch question's own words. + const permanent = EN.console.objectView.bulkDeleteConfirm.split('? ').pop()!; + expect(within(dialog).getByText(EN.objectActions.deleteConfirm).textContent).toContain(permanent); + expect(permanent).toBe('This cannot be undone.'); 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(); @@ -249,6 +261,11 @@ describe('record page Delete asks through the in-app confirm dialog (objectui#11 await waitFor(() => expect(dataSource.delete).toHaveBeenCalledTimes(1)); expect(dataSource.delete).toHaveBeenCalledWith(OBJECT_NAME, RECORD_ID); await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)); + // objectstack-ai/objectstack#27823 — the toast names the deleted record the + // way the confirm title did, not a generic "Record deleted". + expect(toast.success).toHaveBeenCalledWith( + fill(EN.objectActions.deletedNamed, { label: 'Call', name: 'Intro call' }), + ); // …and leaves the deleted record's page for the object's list. await waitFor(() => expect(currentPath()).toBe(`/${OBJECT_NAME}`)); expect(nativeConfirm).not.toHaveBeenCalled(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index ab5f20a6e2c..71f66224427 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -3885,17 +3885,18 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // (`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, 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 } }, - ); + // label, the body is the list's question (it says the delete is + // permanent), and the confirm button is "Delete", painted destructive. + // The success toast names the same record (`recordDelete.successText`). + const deleteDeps = { + objectName: objectName!, + t, + label: objectLabel({ name: objectName!, label: objectDef?.label || objectName! }), + objectDef, + }; + const deleteTarget = + (readablePageRecord as Record | null | undefined) ?? { id: pureRecordId }; + const copy = recordDelete.confirmCopy(deleteDeps, { record: deleteTarget }); // 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 @@ -3911,7 +3912,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ) return; try { await dataSource.delete(objectName!, pureRecordId!); - toast.success(t('detail.deleted', { defaultValue: 'Record deleted' })); + toast.success(recordDelete.successText(deleteDeps, deleteTarget)); const baseAppUrl = appName ? `/apps/${appName}` : ''; navigate(`${baseAppUrl}/${objectName}`, { replace: true }); } catch (err: any) { diff --git a/ui/packages/core/src/actions/__tests__/recordDelete.test.ts b/ui/packages/core/src/actions/__tests__/recordDelete.test.ts index 916fabbbac2..cfe9d89f3b8 100644 --- a/ui/packages/core/src/actions/__tests__/recordDelete.test.ts +++ b/ui/packages/core/src/actions/__tests__/recordDelete.test.ts @@ -194,3 +194,53 @@ describe('recordDelete.run — several records', () => { }); }); + +/** + * objectstack-ai/objectstack#27823 — the delete is permanent and the success + * toast names what went. The name comes from the same ADR-0079 resolver the + * confirm title reads; the resolver's `Record #id` floor is not a name, so a + * nameless record keeps the object-label toast. A batch keeps its count. + */ +describe('recordDelete.successText and the one-record toast (objectstack#27823)', () => { + const TASK = { + name: 'crm_task', + label: 'Task', + nameField: 'subject', + fields: { subject: { type: 'text' }, name: { type: 'text' } }, + }; + + it('names the record by its declared name field and the object label', () => { + expect( + recordDelete.successText( + { t, label: 'Task', objectDef: TASK }, + { id: 't1', subject: 'Website Relaunch', name: 'T-1' }, + ), + ).toBe('objectActions.deletedNamed {"label":"Task","name":"Website Relaunch"}'); + }); + + it('a record with nothing nameable keeps the object-label toast, never "Record #id"', () => { + expect(recordDelete.successText({ t, label: 'Task', objectDef: TASK }, { id: 't9' })).toBe( + 'objectActions.deleteSuccess {"label":"Task"}', + ); + expect(recordDelete.successText({ t, label: 'Task' }, null)).toBe('objectActions.deleteSuccess {"label":"Task"}'); + }); + + it('run names the row it was handed in `params.record`', async () => { + const { deps, toast } = harness(); + await recordDelete.run( + { ...deps, label: 'Task', objectDef: TASK }, + { params: { recordId: 't1', record: { id: 't1', subject: 'Website Relaunch' } } }, + ); + expect(toast.success).toHaveBeenCalledWith('objectActions.deletedNamed {"label":"Task","name":"Website Relaunch"}'); + }); + + it('control: a batch toast keeps its count and names no record', async () => { + const { deps, toast } = harness(); + await recordDelete.run( + { ...deps, label: 'Task', objectDef: TASK }, + { params: { records: [{ id: 'a', subject: 'One' }, { id: 'b', subject: 'Two' }] } }, + ); + expect(toast.success).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith('objectActions.bulkDeleteSuccess {"count":2,"label":"Task"}'); + }); +}); diff --git a/ui/packages/core/src/actions/recordDelete.ts b/ui/packages/core/src/actions/recordDelete.ts index b3dde8a5c58..5dc54382264 100644 --- a/ui/packages/core/src/actions/recordDelete.ts +++ b/ui/packages/core/src/actions/recordDelete.ts @@ -43,6 +43,14 @@ * a batch, the count. The confirm button reads as the deletion it is; every * host paints it in the destructive button style. * + * ## The delete is permanent, and the toast names what went (objectstack#27823) + * + * The platform has no recycle bin, so the one-record body says the delete + * cannot be undone, as the batch body already did. The success toast names + * the deleted record by the same resolver ({@link recordDelete}.successText), + * the counterpart of the record-save toasts; a record with nothing nameable + * keeps the object-label toast. + * * ## The result shape is the action runner's, on purpose * * `run` returns what a registered `delete` handler returns, because the console @@ -71,6 +79,8 @@ interface RecordDeleteDeps { t: Translate; /** The object's display label, used by the toasts. */ label: string; + /** The object definition, so the success toast's name follows its declared `nameField`. */ + objectDef?: unknown; dataSource: { delete(resource: string, id: string): Promise; }; @@ -166,6 +176,25 @@ export const recordDelete = Object.freeze({ }; }, + /** + * The success toast for ONE deleted record: the object label plus the + * record's display name from {@link getRecordDisplayName}. The resolver's + * floor (`Record #`, or empty for an id-less row) is not a name, so such + * a record reads by the object label alone. + */ + successText( + deps: Pick, + record: Row | null | undefined, + ): string { + const { t, label } = deps; + const resolved = record ? getRecordDisplayName(deps.objectDef, record, { fallback: '' }) : ''; + const id = record?.id ?? record?._id; + const name = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`) + ? resolved + : ''; + return name ? t('objectActions.deletedNamed', { label, name }) : t('objectActions.deleteSuccess', { label }); + }, + /** * Delete, refresh and report. Several records (`params.records`, more than * one with an `id`) are deleted one call each and settled together, then the @@ -215,12 +244,13 @@ export const recordDelete = Object.freeze({ const recordId = params?.recordId ?? params?.record?.id ?? records?.[0]?.id ?? request.recordId; + const record = params?.record ?? records?.[0] ?? null; if (!recordId) return { success: false, error: t('objectActions.noRecordId') }; try { await dataSource.delete(objectName, recordId as string); onRefresh?.(); - toast.success(t('objectActions.deleteSuccess', { label })); + toast.success(recordDelete.successText(deps, record)); return { success: true, reload: true, silent: true }; } catch (err) { toast.error(t('objectActions.deleteFailed', { label }), { diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 91d9628d476..30912cb8c93 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -363,7 +363,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `chatbot.plan.iterateOrNew.question` and `.extend`, the plan card's // extend-or-new question and its choice that name the existing app // („{{app}}“) — one interpolated span each, runtime data. - expect(okSpans, 'correctly paired spans').toBe(100); + // 101 once objectstack-ai/objectstack#27823 added + // `objectActions.deletedNamed`, the delete toast that names the record + // („{{name}}“) — one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(101); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -433,7 +436,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // added `preview.changes.draftedByMcpClient`, one more matched pair. // 100 / 100 / 0 after objectstack-ai/objectstack#27480 added // `chatbot.plan.iterateOrNew.question` and `.extend`, one matched pair each. - expect({ open, close, rdq }).toEqual({ open: 100, close: 100, rdq: 0 }); + // 101 / 101 / 0 after objectstack-ai/objectstack#27823 added + // `objectActions.deletedNamed`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 101, close: 101, 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/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts b/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts new file mode 100644 index 00000000000..1990935f5f1 --- /dev/null +++ b/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts @@ -0,0 +1,63 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#27823 — a record delete is permanent (the platform + * has no recycle bin), so every one-record delete question says so, and the + * success toast can name the record. + * + * The batch question (`console.objectView.bulkDeleteConfirm`) already carried + * the pack's "this cannot be undone" sentence. The pin is that both one-record + * questions — the list's and the record page's `objectActions.deleteConfirm`, + * and plugin-detail's `detail.deleteConfirmation` — END WITH THAT SAME + * SENTENCE in every one of the ten packs, read from the pack rather than + * written out here, so a translation can reword it in one place without this + * file moving, and a pack that drops it goes red. + */ + +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales/index'; + +type LocaleCode = keyof typeof builtInLocales; +const LANGS = Object.keys(builtInLocales) as LocaleCode[]; + +function at(pack: unknown, path: string): unknown { + return path.split('.').reduce((node, key) => (node as Record | undefined)?.[key], pack); +} + +/** The sentence after the batch question's question mark (ASCII, full-width or Arabic). */ +function permanenceSentence(lang: LocaleCode): string { + const bulk = at(builtInLocales[lang], 'console.objectView.bulkDeleteConfirm') as string; + const cut = Math.max(bulk.lastIndexOf('?'), bulk.lastIndexOf('?'), bulk.lastIndexOf('؟')); + return bulk.slice(cut + 1).trim(); +} + +describe('record delete says it is permanent and the toast can name the record (objectstack#27823)', () => { + it('reads ten packs, and the en permanence sentence is the house wording', () => { + expect(LANGS).toHaveLength(10); + // Positive control for the extraction: it lands on a real sentence, not ''. + expect(permanenceSentence('en')).toBe('This cannot be undone.'); + }); + + it.each(LANGS)('%s: both one-record delete questions end with the batch question’s permanence sentence', (lang) => { + const sentence = permanenceSentence(lang); + expect(sentence.length, `${lang} permanence sentence`).toBeGreaterThan(3); + for (const key of ['objectActions.deleteConfirm', 'detail.deleteConfirmation']) { + const value = at(builtInLocales[lang], key) as string; + expect(value.endsWith(sentence), `${lang}.${key} = ${value}`).toBe(true); + // The question is still asked before it: the sentence was appended, not swapped in. + expect(value.length, `${lang}.${key}`).toBeGreaterThan(sentence.length + 3); + } + }); + + it.each(LANGS)('%s: the named delete toast interpolates exactly the label and the name', (lang) => { + const value = at(builtInLocales[lang], 'objectActions.deletedNamed') as string; + expect(typeof value).toBe('string'); + expect((value.match(/\{\{\s*\w+\s*\}\}/g) ?? []).sort()).toEqual(['{{label}}', '{{name}}']); + }); +}); diff --git a/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index 17c8ecad291..792aa7d1014 100644 --- a/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -304,10 +304,10 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { } }); - it('the fifteen literal en values are byte-identical to their inline defaultValue', () => { + it('the fourteen literal en values are byte-identical to their inline defaultValue', () => { // Two paths must not diverge: with the pack present i18next answers, and // before this slice the inline default did — a user must not be able to tell - // which ran. 15 keys here; `dashboard.loading` and the two families are the + // which ran. 14 keys here; `dashboard.loading` and the two families are the // three shapes a byte compare cannot reach, each pinned in its own case. // // ⚠️ It was SIXTEEN until objectui#9170. `kanban.columns` left this table @@ -315,6 +315,10 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { // composes a description at all, so there is no inline default to compare // against. ⛔ The key itself was NOT retired here — that is the dead-key // gate's call, not this suite's, and the case below states what it reports. + // ⚠️ FIFTEEN until objectstack#27823: `detail.deleted` left for the same + // reason. The record page's delete toast now names the record through the + // shared `recordDelete.successText` (`objectActions.deletedNamed`), so the + // generic "Record deleted" call site and its inline default are gone. const EXPECTED: Array<[key: string, source: string, value: string]> = [ ['common.done', INVITE_DIALOG, 'Done'], ['common.editInStudio', PAGE_VIEW, 'Edit in studio'], @@ -322,7 +326,6 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { ['common.retry', APP_CONTENT, 'Retry'], ['detail.add', RELATED_LIST, 'Add'], ['detail.concurrentUpdateRecordLabel', SAVE_BAR, 'this record'], - ['detail.deleted', RECORD_DETAIL, 'Record deleted'], ['detail.historyEmpty', DETAIL_VIEW, 'No history yet'], ['empty.appNotAvailable', APP_CONTENT, 'App not available'], [ @@ -345,12 +348,14 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { ['layout.systemNav.documentation', UNIFIED_SIDEBAR, 'Documentation'], ['workspace.multiOrgDisabled', CREATE_WORKSPACE, 'Creating new organizations is disabled on this instance.'], ]; - expect(EXPECTED).toHaveLength(15); - // …and the sixteenth is accounted for rather than merely absent: a row that - // silently disappears from a table like this is how a slice stops covering - // something without anyone noticing. + expect(EXPECTED).toHaveLength(14); + // …and the sixteenth and fifteenth are accounted for rather than merely + // absent: a row that silently disappears from a table like this is how a + // slice stops covering something without anyone noticing. expect(MEASURED_KEYS).toContain('kanban.columns'); expect(EXPECTED.map(([key]) => key)).not.toContain('kanban.columns'); + expect(MEASURED_KEYS).toContain('detail.deleted'); + expect(EXPECTED.map(([key]) => key)).not.toContain('detail.deleted'); const cache = new Map(); for (const [key, rel, value] of EXPECTED) { if (!cache.has(rel)) cache.set(rel, sourceOf(rel)); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 93204d42cd3..c035d7db1a1 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1270,7 +1270,7 @@ const ar = { loading: "جارٍ التحميل…", copyToClipboard: "نسخ إلى الحافظة", copied: "تم النسخ!", - deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟", + deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟ لا يمكن التراجع عن ذلك.", deleted: "تم حذف السجل", editRecord: "تحرير السجل", viewAll: "عرض الكل", @@ -3472,9 +3472,10 @@ const ar = { }, objectActions: { deleteSuccess: "تم حذف {{label}} بنجاح", + deletedNamed: "تم حذف {{label}} \"{{name}}\"", deleteFailed: "تعذر حذف {{label}}", noRecordId: "لم يتم توفير معرف سجل", - deleteConfirm: "حذف هذا السجل؟", + deleteConfirm: "حذف هذا السجل؟ لا يمكن التراجع عن ذلك.", deleteConfirmTitle: "حذف {{label}} \"{{name}}\"؟", bulkDeleteConfirmTitle: "حذف {{count}} سجل(ات) {{label}}؟", bulkDeleteConfirmTitle_zero: "حذف {{count}} من سجلات {{label}}؟", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 6e42688b142..8fe5da7e2ee 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1144,7 +1144,7 @@ const de = { loading: "Wird geladen…", copyToClipboard: "In Zwischenablage kopieren", copied: "Kopiert!", - deleteConfirmation: "Sind Sie sicher, dass Sie diesen Datensatz löschen möchten?", + deleteConfirmation: "Sind Sie sicher, dass Sie diesen Datensatz löschen möchten? Dies kann nicht rückgängig gemacht werden.", deleted: "Datensatz gelöscht", editRecord: "Datensatz bearbeiten", viewAll: "Alle anzeigen", @@ -3223,9 +3223,10 @@ const de = { }, objectActions: { deleteSuccess: "{{label}} erfolgreich gelöscht", + deletedNamed: "{{label}} „{{name}}“ gelöscht", deleteFailed: "{{label}} konnte nicht gelöscht werden", noRecordId: "Keine Datensatz-ID angegeben", - deleteConfirm: "Möchten Sie diesen Datensatz wirklich löschen?", + deleteConfirm: "Möchten Sie diesen Datensatz wirklich löschen? Dies kann nicht rückgängig gemacht werden.", deleteConfirmTitle: "{{label}} „{{name}}“ löschen?", bulkDeleteConfirmTitle: "{{count}} {{label}}-Datensätze löschen?", bulkDeleteConfirmTitle_one: "{{count}} {{label}}-Datensatz löschen?", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 87a2437601b..bec64d0359f 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1489,7 +1489,7 @@ const en = { loading: 'Loading…', copyToClipboard: 'Copy to clipboard', copied: 'Copied!', - deleteConfirmation: 'Are you sure you want to delete this record?', + deleteConfirmation: 'Are you sure you want to delete this record? This cannot be undone.', deleted: 'Record deleted', editRecord: 'Edit record', viewAll: 'View All', @@ -3975,9 +3975,13 @@ const en = { }, objectActions: { deleteSuccess: '{{label}} deleted successfully', + // The delete toast when the deleted record has a display name (ADR-0079), + // the counterpart of `form.createdNamed`; a nameless record keeps + // `deleteSuccess` above. + deletedNamed: '{{label}} "{{name}}" deleted', deleteFailed: 'Failed to delete {{label}}', noRecordId: 'No record ID provided', - deleteConfirm: 'Are you sure you want to delete this record?', + deleteConfirm: 'Are you sure you want to delete this record? This cannot be undone.', deleteConfirmTitle: 'Delete {{label}} "{{name}}"?', bulkDeleteConfirmTitle: 'Delete {{count}} {{label}} records?', bulkDeleteConfirmTitle_one: 'Delete {{count}} {{label}} record?', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index b54edfb9a83..8bdfb28c7cf 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1177,7 +1177,7 @@ const es = { loading: "Cargando…", copyToClipboard: "Copiar al portapapeles", copied: "¡Copiado!", - deleteConfirmation: "¿Está seguro de que desea eliminar este registro?", + deleteConfirmation: "¿Está seguro de que desea eliminar este registro? Esto no se puede deshacer.", deleted: "Registro eliminado", editRecord: "Editar registro", viewAll: "Ver todo", @@ -3286,9 +3286,10 @@ const es = { }, objectActions: { deleteSuccess: "{{label}} eliminado correctamente", + deletedNamed: "{{label}} \"{{name}}\" eliminado", deleteFailed: "No se pudo eliminar {{label}}", noRecordId: "No se proporcionó ID de registro", - deleteConfirm: "¿Eliminar este registro?", + deleteConfirm: "¿Eliminar este registro? Esto no se puede deshacer.", deleteConfirmTitle: "¿Eliminar {{label}} \"{{name}}\"?", bulkDeleteConfirmTitle: "¿Eliminar {{count}} registros de {{label}}?", bulkDeleteConfirmTitle_one: "¿Eliminar {{count}} registro de {{label}}?", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 4d2fded8f2e..9ac6c86a649 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1174,7 +1174,7 @@ const fr = { loading: "Chargement…", copyToClipboard: "Copier dans le presse-papiers", copied: "Copié !", - deleteConfirmation: "Êtes-vous sûr de vouloir supprimer cet enregistrement ?", + deleteConfirmation: "Êtes-vous sûr de vouloir supprimer cet enregistrement ? Ceci est irréversible.", deleted: "Enregistrement supprimé", editRecord: "Modifier l'enregistrement", viewAll: "Tout afficher", @@ -3282,9 +3282,10 @@ const fr = { }, objectActions: { deleteSuccess: "{{label}} supprimé avec succès", + deletedNamed: "{{label}} \"{{name}}\" supprimé", deleteFailed: "Impossible de supprimer {{label}}", noRecordId: "Aucun ID d'enregistrement fourni", - deleteConfirm: "Supprimer cet enregistrement ?", + deleteConfirm: "Supprimer cet enregistrement ? Ceci est irréversible.", deleteConfirmTitle: "Supprimer {{label}} \"{{name}}\" ?", bulkDeleteConfirmTitle: "Supprimer {{count}} enregistrement(s) {{label}} ?", bulkDeleteConfirmTitle_one: "Supprimer {{count}} enregistrement {{label}} ?", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 1b6f5c2cfef..92319801f33 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1144,7 +1144,7 @@ const ja = { loading: "読み込み中…", copyToClipboard: "クリップボードにコピー", copied: "コピーしました!", - deleteConfirmation: "このレコードを削除してもよろしいですか?", + deleteConfirmation: "このレコードを削除してもよろしいですか?この操作は元に戻せません。", deleted: "レコードを削除しました", editRecord: "レコードを編集", viewAll: "すべて表示", @@ -3225,9 +3225,10 @@ const ja = { }, objectActions: { deleteSuccess: "{{label}} を削除しました", + deletedNamed: "{{label}}「{{name}}」を削除しました", deleteFailed: "{{label}} の削除に失敗しました", noRecordId: "レコードIDが指定されていません", - deleteConfirm: "このレコードを削除してもよろしいですか?", + deleteConfirm: "このレコードを削除してもよろしいですか?この操作は元に戻せません。", deleteConfirmTitle: "{{label}}「{{name}}」を削除しますか?", bulkDeleteConfirmTitle: "{{count}} 件の {{label}} レコードを削除しますか?", bulkDeleteConfirmTitle_one: "{{count}} 件の {{label}} レコードを削除しますか?", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 6a415327261..a7448288cbc 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1144,7 +1144,7 @@ const ko = { loading: "로딩 중…", copyToClipboard: "클립보드에 복사", copied: "복사됨!", - deleteConfirmation: "이 레코드를 삭제하시겠습니까?", + deleteConfirmation: "이 레코드를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", deleted: "레코드가 삭제됨", editRecord: "레코드 편집", viewAll: "모두 보기", @@ -3222,9 +3222,10 @@ const ko = { }, objectActions: { deleteSuccess: "{{label}} 삭제 성공", + deletedNamed: "{{label}} \"{{name}}\" 삭제됨", deleteFailed: "{{label}} 삭제 실패", noRecordId: "레코드 ID가 없습니다", - deleteConfirm: "이 레코드를 삭제하시겠습니까?", + deleteConfirm: "이 레코드를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", deleteConfirmTitle: "{{label}} \"{{name}}\"을(를) 삭제하시겠습니까?", bulkDeleteConfirmTitle: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", bulkDeleteConfirmTitle_one: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 06b841e6f53..22421753e03 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1174,7 +1174,7 @@ const pt = { loading: "Carregando…", copyToClipboard: "Copiar para área de transferência", copied: "Copiado!", - deleteConfirmation: "Tem certeza de que deseja excluir este registro?", + deleteConfirmation: "Tem certeza de que deseja excluir este registro? Isso não pode ser desfeito.", deleted: "Registro excluído", editRecord: "Editar registro", viewAll: "Ver tudo", @@ -3281,9 +3281,10 @@ const pt = { }, objectActions: { deleteSuccess: "{{label}} excluído com sucesso", + deletedNamed: "{{label}} \"{{name}}\" excluído", deleteFailed: "Não foi possível excluir {{label}}", noRecordId: "Nenhum ID de registro fornecido", - deleteConfirm: "Excluir este registro?", + deleteConfirm: "Excluir este registro? Isso não pode ser desfeito.", deleteConfirmTitle: "Excluir {{label}} \"{{name}}\"?", bulkDeleteConfirmTitle: "Excluir {{count}} registros de {{label}}?", bulkDeleteConfirmTitle_one: "Excluir {{count}} registro de {{label}}?", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 7ae453f4ce4..c1ffcb6cbd3 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1211,7 +1211,7 @@ const ru = { loading: "Загрузка…", copyToClipboard: "Копировать в буфер обмена", copied: "Скопировано!", - deleteConfirmation: "Вы уверены, что хотите удалить эту запись?", + deleteConfirmation: "Вы уверены, что хотите удалить эту запись? Это действие нельзя отменить.", deleted: "Запись удалена", editRecord: "Редактировать запись", viewAll: "Показать все", @@ -3358,9 +3358,10 @@ const ru = { }, objectActions: { deleteSuccess: "{{label}} успешно удалён", + deletedNamed: "{{label}} \"{{name}}\" удалён", deleteFailed: "Не удалось удалить {{label}}", noRecordId: "ID записи не указан", - deleteConfirm: "Удалить эту запись?", + deleteConfirm: "Удалить эту запись? Это действие нельзя отменить.", deleteConfirmTitle: "Удалить {{label}} \"{{name}}\"?", bulkDeleteConfirmTitle: "Удалить {{count}} записей {{label}}?", bulkDeleteConfirmTitle_one: "Удалить {{count}} запись {{label}}?", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 83704909f90..902a5d7fec9 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1223,7 +1223,7 @@ const zh = { loading: '加载中…', copyToClipboard: '复制到剪贴板', copied: '已复制!', - deleteConfirmation: '确定要删除此记录吗?', + deleteConfirmation: '确定要删除此记录吗?此操作无法撤销。', deleted: '记录已删除', editRecord: '编辑记录', viewAll: '查看全部', @@ -3436,9 +3436,10 @@ const zh = { }, objectActions: { deleteSuccess: '{{label}} 删除成功', + deletedNamed: '{{label}}“{{name}}”已删除', deleteFailed: '删除 {{label}} 失败', noRecordId: '未提供记录 ID', - deleteConfirm: '确定要删除此记录吗?', + deleteConfirm: '确定要删除此记录吗?此操作无法撤销。', deleteConfirmTitle: '删除{{label}}“{{name}}”?', bulkDeleteConfirmTitle: '删除 {{count}} 条 {{label}} 记录?', bulkDeleteConfirmTitle_one: '删除 {{count}} 条 {{label}} 记录?', diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index 46f12b73541..ab423dd671e 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -97,7 +97,7 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.loading': 'Loading…', 'detail.copyToClipboard': 'Copy to clipboard', 'detail.copied': 'Copied!', - 'detail.deleteConfirmation': 'Are you sure you want to delete this record?', + 'detail.deleteConfirmation': 'Are you sure you want to delete this record? This cannot be undone.', 'detail.editRecord': 'Edit record', 'detail.viewAll': 'View All', 'detail.new': 'New', diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index 2cba58bc953..e6b0bd354b5 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -641,13 +641,14 @@ const VIEW_DEFAULT_TRANSLATIONS: Record = { '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?', + 'objectActions.deleteConfirm': 'Are you sure you want to delete this record? This cannot be undone.', 'console.objectView.bulkDeleteConfirm': 'Delete {{count}} selected records? This cannot be undone.', // Count families (objectui#11445): `fallbackT` reads a family's `_one` / // `_other` row for a numeric `count`, as i18next reads the `en` pack. 'console.objectView.bulkDeleteConfirm_one': 'Delete {{count}} selected record? This cannot be undone.', 'console.objectView.bulkDeleteConfirm_other': 'Delete {{count}} selected records? This cannot be undone.', 'objectActions.deleteSuccess': '{{label}} deleted successfully', + 'objectActions.deletedNamed': '{{label}} "{{name}}" deleted', 'objectActions.deleteFailed': 'Failed to delete {{label}}', 'objectActions.bulkDeleteSuccess': 'Deleted {{count}} {{label}} records', 'objectActions.bulkDeleteSuccess_one': 'Deleted {{count}} {{label}} record', @@ -3377,6 +3378,7 @@ export const ObjectView: React.FC = ({ { objectName: schema.objectName, label: deleteLabel, + objectDef: objectSchema, dataSource, t: tView, toast, diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx index fe751128f51..fead69e608e 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx @@ -24,13 +24,15 @@ * * 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 + * - a row asks "Are you sure you want to delete this record? This cannot be + * undone." (objectstack-ai/objectstack#27823); a selection * 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` / + * - success toasts `{{label}} "{{name}}" deleted` (the row's name, + * objectstack-ai/objectstack#27823) / * `Deleted N {{label}} records`; a failure toasts `Failed to delete * {{label}}` (with the error message) or `N deleted, M failed`; * - whether the affordance is offered at all is `ObjectGrid`'s own verdict, @@ -224,7 +226,9 @@ describe('objectui#10383 — row Delete on the registered object-view path', () await userEvent.click(deleteItem!); const dialog = await confirmDialog(); - expect(within(dialog).getByText('Are you sure you want to delete this record?')).toBeInTheDocument(); + expect( + within(dialog).getByText('Are you sure you want to delete this record? This cannot be undone.'), + ).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"?'); @@ -239,7 +243,7 @@ describe('objectui#10383 — row Delete on the registered object-view path', () expect(ds.delete).toHaveBeenCalledWith(OBJECT, 'r1'); await waitFor(() => expect(screen.queryByText('Alice')).not.toBeInTheDocument()); expect(screen.getByText('Bob')).toBeInTheDocument(); - expect(successSpy).toHaveBeenCalledWith('Test object deleted successfully'); + expect(successSpy).toHaveBeenCalledWith('Test object "Alice" deleted'); expect(errorSpy).not.toHaveBeenCalled(); });