Skip to content

Commit 8c3f9d9

Browse files
feat(console): the row and record-page Delete confirmations name the objects a delete cascades into, from the served cascadeDeleteObjects (#28490)
domain:ui#1 · os-tesla · session_01L9qibvGtYbnKXjz3iks1AW <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao496Uyi2VsF6sZMEh4MGR9S)_ Fixes #28472 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:列表行删除、批量删除和记录页删除的确认框,在平台下发 `cascadeDeleteObjects` 非空时多一句话,按当前语言的对象名称列出会被一并删除的子对象(例如删除线索会一并删除「Campaign Member」记录)。 - 为什么改:#28001 裁决 B 的 UI 半边;服务端已在 #28451 下发该列表,但 Console 的数据层把它丢了,用户删除时不知道会连带删掉子记录。 - 风险与回滚:只加一句文案;列表为空或未下发时文案与今天逐字相同;Console 不自行推导级联。回滚即整体 revert 本 PR。 - 先看:`ui/packages/core/src/actions/recordDelete.ts`(`confirmCopy` 追加一句)、`ui/packages/app-shell/src/hooks/useObjectActions.ts`(`resolveCascadeDeleteLabels` 按名读对象 schema)、`ui/packages/data-objectstack/src/index.ts`(`fetchObjectSchemaFresh` 保留信封里的列表)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx packages/app-shell/src/views/RecordDetailView.deleteCascade-28472.test.tsx`,预期 2 个文件 9 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes The platform serves `cascadeDeleteObjects` beside `item` on the by-name object read and on the app bundle's object entries (#28451). It never reached a confirmation, for two measured reasons: 1. `@object-ui/data-objectstack` `fetchObjectSchemaFresh` unwraps `item` and drops the envelope's siblings (only `sortability` and `disabledActions` were re-attached). 2. Both Console hosts pass the `objectDef` from `useMetadata().objects`, which is the LIST read, where the server never serves the key. The change: - **`@object-ui/core`**: new `utils/cascade-delete-objects.ts` with `CASCADE_DELETE_OBJECTS_KEY`, `attachCascadeDeleteObjects` and `readCascadeDeleteObjects`, the twin of `attachObjectSortability`: a non-enumerable symbol, so a schema written back to a metadata endpoint never carries the list. `[]` stays `[]`; absent stays `undefined`. `recordDelete.confirmCopy` takes an optional `cascadeLabels` and appends one sentence (`objectActions.deleteCascade`) to the body, for one record and for a batch. Empty or absent adds nothing. - **`@object-ui/data-objectstack`**: `fetchObjectSchemaFresh` attaches the envelope's `cascadeDeleteObjects`. The app-bundle path answers the same envelope, so it is covered by the same line. - **`@object-ui/app-shell`**: `useObjectActions` exports `resolveCascadeDeleteLabels(dataSource, objectName, labelOf)`, which reads the by-name schema (`dataSource.getObjectSchema`, already cached by the adapter because the grid and the record form read it) and maps the served names to labels; a failed read answers `undefined`. `useObjectActions` takes an optional `objectLabelOf`; `views/ObjectView.tsx` passes one (the `objects` entry through `useObjectLabel().objectLabel`, else the name), and `views/RecordDetailView.tsx` uses the same helper in its `sys_delete` confirm. Both host edits are a few lines. - **`@object-ui/i18n`**: `objectActions.deleteCascade` in all ten built-in catalogs. - `plugin-grid` is untouched: it has no confirm of its own, it hands the row to the host's `onDelete`. No author key (ruling B item 1), and no client copy of the cascade rule (triage 6549643166). ## Tests - `ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx` (row and selection, real `I18nProvider`, strings read from the packs by key): list `['crm_campaign_member']` adds one line naming `Campaign Member` (en and zh), `[]` adds nothing, absent adds nothing, a failed schema read adds nothing, the batch question gains the same line. - `ui/packages/app-shell/src/views/RecordDetailView.deleteCascade-28472.test.tsx` (the real record page and the real `ActionConfirmDialog`): list adds one line naming its label, `[]` and absent leave the body exactly today's question. - `ui/packages/data-objectstack/src/getObjectSchema.cascadeDeleteObjects-28472.test.ts`: the envelope's list is carried, off enumerable keys and out of `JSON.stringify`; `[]` stays `[]`; absent stays absent. - `ui/packages/core/src/actions/__tests__/recordDelete.cascade-28472.test.ts`: `confirmCopy` with and without labels, batch, and the carried-key pair. Ablation (one run, not kept): `node scripts/ablation-replace.mjs` replaced the reader in `resolveCascadeDeleteLabels` (the `readCascadeDeleteObjects(schema)` map) with a bare return. The two host files went 4 failed, 5 passed: every list-positive pin red (row en, row zh, batch, record page), every `[]` / absent / failed-read pin green. Restored, blob back to the HEAD blob `7742aef6`. ## Acceptance notes - **Bulk delete gets the line too.** It asks through the same `confirmCopy` and deletes records that cascade the same way (seat ruling in claim amendment 6550299396). - **`plugin-view`'s own `ObjectView` delete dialog is unchanged.** It also calls `confirmCopy`, but passes no `cascadeLabels`, so it keeps today's text. The Console list route uses `app-shell`'s `ObjectView`. - **Labels.** A cascade object the Console's `objects` list holds is named by its translated label; one it does not hold (a scoped record route lists only the objects the page binds) falls back to the translation bundle's label, else its name. - **Separator.** Labels are joined with a comma and a space in every language. - `RecordDetailView.tsx` and `ObjectView.tsx` are also edited by PR #28477 and sibling #28461; the edits here are a few lines each. - **main-red fix carried:** ba0084e (#28467) added ActionSchema.overrides without the ui inventory entry; actionKeys.pin red on main for every ui PR. `SPEC_ACTION_KEYS` in `ui/packages/core/src/actions/actionKeys.ts` gains `'overrides'` (restates the spec key; `ActionDef` does not declare it, the runner does not read it). On head `4e795d9e` (main merged at `9b4d1933`): `packages/core/src/actions/` 51 files, 717 tests passed; with the entry deleted (`ablation-replace.mjs`) the pin reds with `missing: ['overrides']`. ## Verification All on head `e1ded0b8` (main base `768ec4bf`), except the main-red fix above, measured on `4e795d9e`. <details><summary>Tests</summary> - New files plus the existing delete pins (`recordDelete.test.ts`, `useObjectActions.deleteDialog-11695`, `useObjectActions.test`, `RecordDetailView.deleteConfirmDialog-11001`): 8 files, 51 tests passed. - Package suites `packages/i18n/ packages/core/ packages/data-objectstack/`: 432 files; 3 files red on a dts-less spec build (`actionKeys.types`, `spec-symbol-batch4`, `spec-symbol-batch6`), green (3 files, 77 tests) after `env -u OS_SKIP_DTS pnpm --filter @objectstack/spec build`. - `packages/app-shell/src/hooks/`, `packages/app-shell/src/views/ObjectView*`, `packages/app-shell/src/views/RecordDetailView*`: 262 files, 2006 tests passed. - `type-check` for `@object-ui/core`, `@object-ui/data-objectstack`, `@object-ui/i18n`, `@object-ui/app-shell`: exit 0 (after a full dts build of their closure). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 families, every one exit 0, including `node scripts/check-sdui-manifest.mjs` and its self-test, `pnpm check:cross-package-test-inputs`, `pnpm check:test-source-alias`, `pnpm check:published-files`, `pnpm check:undeclared-dep-imports`, `node scripts/check-changeset-no-major.mjs --base origin/main`, and `pnpm --dir ui exec vitest run scripts/` (171 files, 4901 passed, 2 skipped). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED. `pnpm check:new-line-citations` (ui): 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvGtYbnKXjz3iks1AW)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b4d193 commit 8c3f9d9

23 files changed

Lines changed: 663 additions & 7 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/data-objectstack': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(console): the row and record-page Delete confirmations name the objects a delete will cascade into, from the platform's served `cascadeDeleteObjects`
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
A delete can also delete child records, for example a lead's campaign memberships. The platform serves which objects, by name, beside the by-name object read (`cascadeDeleteObjects`). The Console now shows them:
14+
15+
- **The confirmation gains one line.** The row Delete, the selection's bulk Delete and the record page's Delete add one sentence naming those objects by their labels, in the user's language (new key `objectActions.deleteCascade`, in all ten built-in catalogs). An object the Console holds no label for reads by its name.
16+
- **Nothing extra when nothing cascades.** A served empty list adds nothing. When the platform served no answer (an older backend, a draft read), the confirmation keeps today's text and says nothing about children. The Console never works the cascade out for itself.
17+
- **How the list travels.** `@object-ui/data-objectstack` `getObjectSchema` keeps the envelope's `cascadeDeleteObjects` on the schema it returns, under a non-enumerable symbol, so a schema written back to a metadata endpoint never carries it. `@object-ui/core` exports `attachCascadeDeleteObjects`, `readCascadeDeleteObjects` and `CASCADE_DELETE_OBJECTS_KEY`, and `recordDelete.confirmCopy` takes an optional `cascadeLabels` list. `@object-ui/app-shell` `useObjectActions` takes an optional `objectLabelOf` and resolves the labels itself.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#28472 — the console list's row Delete (and the selection's)
11+
* names the objects the delete cascades into, from the served
12+
* `cascadeDeleteObjects`, by label.
13+
*
14+
* The list's `objectDef` comes from the list read, which never serves the key,
15+
* so the hook reads the by-name schema the adapter carries it on
16+
* (`dataSource.getObjectSchema`). The data source here hands back exactly what
17+
* the adapter would: a schema with the served list attached under its symbol.
18+
* Strings are read from the packs by KEY under a real `I18nProvider`.
19+
*/
20+
21+
import * as React from 'react';
22+
import { describe, it, expect, vi, afterEach } from 'vitest';
23+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
24+
import { MemoryRouter } from 'react-router-dom';
25+
import { I18nProvider } from '@object-ui/i18n';
26+
import { builtInLocales } from '@object-ui/i18n/locales';
27+
import { attachCascadeDeleteObjects, type ConfirmationHandler } from '@object-ui/core';
28+
29+
vi.mock('sonner', () => ({
30+
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
31+
}));
32+
33+
import { useObjectActions } from '../useObjectActions';
34+
35+
type Pack = {
36+
objectActions: { deleteConfirm: string; deleteCascade: string };
37+
console: { objectView: { bulkDeleteConfirm_other: string } };
38+
};
39+
const EN = builtInLocales.en as unknown as Pack;
40+
const ZH = builtInLocales.zh as unknown as Pack;
41+
const fill = (template: string, vars: Record<string, string | number>) =>
42+
template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(vars[k]));
43+
44+
const LEAD = { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text' } } };
45+
const LABELS: Record<string, string> = { crm_campaign_member: 'Campaign Member' };
46+
const objectLabelOf = (name: string) => LABELS[name] ?? name;
47+
48+
function Harness({ dataSource, onConfirm }: { dataSource: any; onConfirm: ConfirmationHandler }) {
49+
const actions = useObjectActions({
50+
objectName: LEAD.name,
51+
objectLabel: LEAD.label,
52+
objectDef: LEAD,
53+
dataSource,
54+
onConfirm,
55+
objectLabelOf,
56+
});
57+
return (
58+
<>
59+
<button onClick={() => void actions.deleteRecord('l1', { id: 'l1', name: 'Ada' })}>row-delete</button>
60+
<button onClick={() => void actions.deleteRecords([{ id: 'a' }, { id: 'b' }])}>bulk-delete</button>
61+
</>
62+
);
63+
}
64+
65+
/** Mount with a data source whose by-name schema read answers `served`. */
66+
function mount(served: unknown, language: 'en' | 'zh' = 'en') {
67+
const getObjectSchema = vi.fn(async () =>
68+
served === 'throw'
69+
? Promise.reject(new Error('offline'))
70+
: attachCascadeDeleteObjects(structuredClone(LEAD), served),
71+
);
72+
const dataSource = { delete: vi.fn(async () => ({})), getObjectSchema };
73+
const onConfirm = vi.fn<ConfirmationHandler>(async () => false);
74+
render(
75+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
76+
<MemoryRouter initialEntries={['/apps/crm/crm_lead']}>
77+
<Harness dataSource={dataSource} onConfirm={onConfirm} />
78+
</MemoryRouter>
79+
</I18nProvider>,
80+
);
81+
return { onConfirm, getObjectSchema, dataSource };
82+
}
83+
84+
async function askedMessage(onConfirm: ReturnType<typeof vi.fn>, button: string): Promise<string> {
85+
fireEvent.click(screen.getByText(button));
86+
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
87+
return onConfirm.mock.calls[0][0] as string;
88+
}
89+
90+
afterEach(() => {
91+
cleanup();
92+
vi.clearAllMocks();
93+
});
94+
95+
describe('console row Delete names the cascade objects (objectstack#28472)', () => {
96+
it('a served list of one object adds one line naming its label', async () => {
97+
const { onConfirm, getObjectSchema } = mount(['crm_campaign_member']);
98+
const message = await askedMessage(onConfirm, 'row-delete');
99+
expect(getObjectSchema).toHaveBeenCalledWith('crm_lead');
100+
expect(message).toBe(
101+
`${EN.objectActions.deleteConfirm} ${fill(EN.objectActions.deleteCascade, { objects: 'Campaign Member' })}`,
102+
);
103+
expect(message).not.toContain('crm_campaign_member');
104+
});
105+
106+
it('the same line in zh', async () => {
107+
const { onConfirm } = mount(['crm_campaign_member'], 'zh');
108+
const message = await askedMessage(onConfirm, 'row-delete');
109+
expect(ZH.objectActions.deleteCascade).not.toBe(EN.objectActions.deleteCascade);
110+
expect(message).toBe(
111+
`${ZH.objectActions.deleteConfirm} ${fill(ZH.objectActions.deleteCascade, { objects: 'Campaign Member' })}`,
112+
);
113+
});
114+
115+
it('a served empty list adds nothing', async () => {
116+
const { onConfirm } = mount([]);
117+
expect(await askedMessage(onConfirm, 'row-delete')).toBe(EN.objectActions.deleteConfirm);
118+
});
119+
120+
it('no served list adds nothing and keeps today\'s text', async () => {
121+
const { onConfirm } = mount(undefined);
122+
expect(await askedMessage(onConfirm, 'row-delete')).toBe(EN.objectActions.deleteConfirm);
123+
});
124+
125+
it('a failed schema read adds nothing and still asks', async () => {
126+
const { onConfirm, dataSource } = mount('throw');
127+
expect(await askedMessage(onConfirm, 'row-delete')).toBe(EN.objectActions.deleteConfirm);
128+
expect(dataSource.delete).not.toHaveBeenCalled();
129+
});
130+
131+
it('a selection gains the same line after the batch question', async () => {
132+
const { onConfirm } = mount(['crm_campaign_member']);
133+
const message = await askedMessage(onConfirm, 'bulk-delete');
134+
expect(message).toBe(
135+
`${fill(EN.console.objectView.bulkDeleteConfirm_other, { count: 2 })} ${fill(EN.objectActions.deleteCascade, { objects: 'Campaign Member' })}`,
136+
);
137+
});
138+
});

‎ui/packages/app-shell/src/hooks/useObjectActions.ts‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
1818
import { toast } from 'sonner';
1919
import {
2020
recordDelete,
21+
readCascadeDeleteObjects,
2122
type ActionDef,
2223
type ActionResult,
2324
type ConfirmationHandler,
@@ -27,6 +28,30 @@ import {
2728
/** The browser's native box — the fallback when the host passes no `onConfirm`. */
2829
const nativeConfirm: ConfirmationHandler = async (message) => window.confirm(message);
2930

31+
/**
32+
* The labels of the objects a delete of `objectName` cascades into, for the
33+
* delete confirmation (objectstack#28472). The list is the platform's served
34+
* `cascadeDeleteObjects`, read off the by-name object schema the adapter
35+
* carries it on: a host's `objects` entry comes from the list read, which
36+
* never serves it. `undefined` when no answer was served or the read failed,
37+
* so the confirmation then says nothing about children. Each name is resolved
38+
* by `labelOf`; a host that passes none shows the names.
39+
*/
40+
export async function resolveCascadeDeleteLabels(
41+
dataSource: { getObjectSchema?: (objectName: string) => Promise<unknown> } | null | undefined,
42+
objectName: string,
43+
labelOf: (objectName: string) => string = (name) => name,
44+
): Promise<string[] | undefined> {
45+
if (typeof dataSource?.getObjectSchema !== 'function') return undefined;
46+
let schema: unknown;
47+
try {
48+
schema = await dataSource.getObjectSchema(objectName);
49+
} catch {
50+
return undefined;
51+
}
52+
return readCascadeDeleteObjects(schema)?.map((name) => labelOf(name));
53+
}
54+
3055
interface ObjectActionConfig {
3156
objectName: string;
3257
objectLabel?: string;
@@ -42,6 +67,12 @@ interface ObjectActionConfig {
4267
onConfirm?: ConfirmationHandler;
4368
/** Optional toast handler — falls back to sonner */
4469
onToast?: (message: string, options?: { type?: string }) => void;
70+
/**
71+
* Resolve another object's name to its label in the session's language, so
72+
* the delete confirmation names the objects the delete cascades into
73+
* (objectstack#28472). Without it those objects read by name.
74+
*/
75+
objectLabelOf?: (objectName: string) => string;
4576
}
4677

4778
interface ObjectActions {
@@ -75,6 +106,7 @@ export function useObjectActions({
75106
onRefresh,
76107
onConfirm,
77108
onToast,
109+
objectLabelOf,
78110
}: ObjectActionConfig): ObjectActions {
79111
const navigate = useNavigate();
80112
const { appName } = useParams();
@@ -158,8 +190,9 @@ export function useObjectActions({
158190
// The copy comes from the shared record-delete core (objectui#10383).
159191
// With only an id in hand the title names the record by the resolver's
160192
// `Record #id` floor.
193+
const cascadeLabels = await resolveCascadeDeleteLabels(dataSource, objectName, objectLabelOf);
161194
const copy = recordDelete.confirmCopy(
162-
{ objectName, t, label, objectDef },
195+
{ objectName, t, label, objectDef, cascadeLabels },
163196
{ record: record ?? { id: recordId } },
164197
);
165198
if (!(await confirmDelete(copy))) return { success: false, error: 'Action cancelled by user' };
@@ -168,19 +201,20 @@ export function useObjectActions({
168201
params: record ? { recordId, record } : { recordId },
169202
});
170203
},
171-
[execute, t, objectName, label, objectDef, confirmDelete],
204+
[execute, t, objectName, label, objectDef, confirmDelete, dataSource, objectLabelOf],
172205
);
173206

174207
const deleteRecords = useCallback(
175208
async (records: Record<string, unknown>[]): Promise<ActionResult> => {
209+
const cascadeLabels = await resolveCascadeDeleteLabels(dataSource, objectName, objectLabelOf);
176210
const copy = recordDelete.confirmCopy(
177-
{ objectName, t, label, objectDef },
211+
{ objectName, t, label, objectDef, cascadeLabels },
178212
{ count: records.length },
179213
);
180214
if (!(await confirmDelete(copy))) return { success: false, error: 'Action cancelled by user' };
181215
return execute({ type: 'delete', params: { records } });
182216
},
183-
[execute, t, objectName, label, objectDef, confirmDelete],
217+
[execute, t, objectName, label, objectDef, confirmDelete, dataSource, objectLabelOf],
184218
);
185219

186220
const navigateToView = useCallback(

‎ui/packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
712712
// Action system for toolbar operations — refreshKey moved up (declared earlier).
713713
// Wired to confirmHandler/toastHandler so deletes use the Shadcn AlertDialog
714714
// and Sonner toast instead of native window.confirm.
715+
// objectstack#28472 — the objects a delete cascades into, named by label.
716+
const objectLabelOf = useCallback(
717+
(name: string) => objectLabel(objects.find((o: any) => o.name === name) ?? { name, label: name }),
718+
[objects, objectLabel],
719+
);
715720
const actions = useObjectActions({
716721
objectName: objectDef.name,
717722
// The label this page shows (its header and breadcrumb read the same
@@ -724,6 +729,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
724729
onEdit,
725730
onRefresh: refreshData,
726731
onConfirm: confirmHandler,
732+
objectLabelOf,
727733
// ToastHandler's `type` is a string-literal union — a subset of the
728734
// looser `{ type?: string }` useObjectActions declares; cast is sound.
729735
onToast: toastHandler as (message: string, options?: { type?: string }) => void,

0 commit comments

Comments
 (0)