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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/27823-record-delete-permanent-named.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions ui/packages/app-shell/src/hooks/useObjectActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
25 changes: 13 additions & 12 deletions ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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
Expand All @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions ui/packages/core/src/actions/__tests__/recordDelete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}');
});
});
32 changes: 31 additions & 1 deletion ui/packages/core/src/actions/recordDelete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<unknown>;
};
Expand Down Expand Up @@ -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 #<id>`, or empty for an id-less row) is not a name, so such
* a record reads by the object label alone.
*/
successText(
deps: Pick<RecordDeleteDeps, 't' | 'label' | 'objectDef'>,
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
Expand Down Expand Up @@ -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 }), {
Expand Down
9 changes: 7 additions & 2 deletions ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(“)', () => {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>((node, key) => (node as Record<string, unknown> | 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}}']);
});
});
19 changes: 12 additions & 7 deletions ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,25 +304,28 @@ 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
// because its call site left the tree: the board-level empty state no longer
// 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'],
['common.record', APP_CONTENT, 'Record'],
['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'],
[
Expand All @@ -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<string, string>();
for (const [key, rel, value] of EXPECTED) {
if (!cache.has(rel)) cache.set(rel, sourceOf(rel));
Expand Down
Loading
Loading