From 53c15e005c55b6a4ddc629c9a2b0f2e7e1c44399 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:02:55 +0000 Subject: [PATCH 1/5] feat(console): delete confirmations name the objects a delete cascades into, from the served list Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvGtYbnKXjz3iks1AW --- .../app-shell/src/hooks/useObjectActions.ts | 42 +++++++++++-- .../app-shell/src/views/ObjectView.tsx | 6 ++ .../app-shell/src/views/RecordDetailView.tsx | 6 +- ui/packages/core/src/actions/recordDelete.ts | 23 ++++++- ui/packages/core/src/index.ts | 3 + .../core/src/utils/cascade-delete-objects.ts | 63 +++++++++++++++++++ ui/packages/data-objectstack/src/index.ts | 6 ++ ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 17 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 ui/packages/core/src/utils/cascade-delete-objects.ts diff --git a/ui/packages/app-shell/src/hooks/useObjectActions.ts b/ui/packages/app-shell/src/hooks/useObjectActions.ts index 165348cc431..7742aef6630 100644 --- a/ui/packages/app-shell/src/hooks/useObjectActions.ts +++ b/ui/packages/app-shell/src/hooks/useObjectActions.ts @@ -18,6 +18,7 @@ import { useObjectTranslation } from '@object-ui/i18n'; import { toast } from 'sonner'; import { recordDelete, + readCascadeDeleteObjects, type ActionDef, type ActionResult, type ConfirmationHandler, @@ -27,6 +28,30 @@ import { /** The browser's native box — the fallback when the host passes no `onConfirm`. */ const nativeConfirm: ConfirmationHandler = async (message) => window.confirm(message); +/** + * The labels of the objects a delete of `objectName` cascades into, for the + * delete confirmation (objectstack#28472). The list is the platform's served + * `cascadeDeleteObjects`, read off the by-name object schema the adapter + * carries it on: a host's `objects` entry comes from the list read, which + * never serves it. `undefined` when no answer was served or the read failed, + * so the confirmation then says nothing about children. Each name is resolved + * by `labelOf`; a host that passes none shows the names. + */ +export async function resolveCascadeDeleteLabels( + dataSource: { getObjectSchema?: (objectName: string) => Promise } | null | undefined, + objectName: string, + labelOf: (objectName: string) => string = (name) => name, +): Promise { + if (typeof dataSource?.getObjectSchema !== 'function') return undefined; + let schema: unknown; + try { + schema = await dataSource.getObjectSchema(objectName); + } catch { + return undefined; + } + return readCascadeDeleteObjects(schema)?.map((name) => labelOf(name)); +} + interface ObjectActionConfig { objectName: string; objectLabel?: string; @@ -42,6 +67,12 @@ interface ObjectActionConfig { onConfirm?: ConfirmationHandler; /** Optional toast handler — falls back to sonner */ onToast?: (message: string, options?: { type?: string }) => void; + /** + * Resolve another object's name to its label in the session's language, so + * the delete confirmation names the objects the delete cascades into + * (objectstack#28472). Without it those objects read by name. + */ + objectLabelOf?: (objectName: string) => string; } interface ObjectActions { @@ -75,6 +106,7 @@ export function useObjectActions({ onRefresh, onConfirm, onToast, + objectLabelOf, }: ObjectActionConfig): ObjectActions { const navigate = useNavigate(); const { appName } = useParams(); @@ -158,8 +190,9 @@ export function useObjectActions({ // The copy comes from the shared record-delete core (objectui#10383). // With only an id in hand the title names the record by the resolver's // `Record #id` floor. + const cascadeLabels = await resolveCascadeDeleteLabels(dataSource, objectName, objectLabelOf); const copy = recordDelete.confirmCopy( - { objectName, t, label, objectDef }, + { objectName, t, label, objectDef, cascadeLabels }, { record: record ?? { id: recordId } }, ); if (!(await confirmDelete(copy))) return { success: false, error: 'Action cancelled by user' }; @@ -168,19 +201,20 @@ export function useObjectActions({ params: record ? { recordId, record } : { recordId }, }); }, - [execute, t, objectName, label, objectDef, confirmDelete], + [execute, t, objectName, label, objectDef, confirmDelete, dataSource, objectLabelOf], ); const deleteRecords = useCallback( async (records: Record[]): Promise => { + const cascadeLabels = await resolveCascadeDeleteLabels(dataSource, objectName, objectLabelOf); const copy = recordDelete.confirmCopy( - { objectName, t, label, objectDef }, + { objectName, t, label, objectDef, cascadeLabels }, { 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], + [execute, t, objectName, label, objectDef, confirmDelete, dataSource, objectLabelOf], ); const navigateToView = useCallback( diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index ea2fddf910a..1f8ef49d649 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -715,6 +715,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // Action system for toolbar operations — refreshKey moved up (declared earlier). // Wired to confirmHandler/toastHandler so deletes use the Shadcn AlertDialog // and Sonner toast instead of native window.confirm. + // objectstack#28472 — the objects a delete cascades into, named by label. + const objectLabelOf = useCallback( + (name: string) => objectLabel(objects.find((o: any) => o.name === name) ?? { name, label: name }), + [objects, objectLabel], + ); const actions = useObjectActions({ objectName: objectDef.name, // The label this page shows (its header and breadcrumb read the same @@ -727,6 +732,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co onEdit, onRefresh: refreshData, onConfirm: confirmHandler, + objectLabelOf, // ToastHandler's `type` is a string-literal union — a subset of the // looser `{ type?: string }` useObjectActions declares; cast is sound. onToast: toastHandler as (message: string, options?: { type?: string }) => void, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 1ed44015143..f37ac7ad2a0 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -80,6 +80,7 @@ import { } from '../utils/workspacePreference.js'; import { shortcutKeycaps, useAdvertiseShortcut, type AdvertisedShortcut, type ShortcutChord, type ShortcutLabelTranslate } from '../chrome/advertisedShortcuts.js'; import { useActionModal } from '../hooks/useActionModal.js'; +import { resolveCascadeDeleteLabels } from '../hooks/useObjectActions.js'; import { useRecentItems } from '../hooks/useRecentItems.js'; import { useObjectPresence } from '../hooks/useObjectPresence.js'; @@ -4095,7 +4096,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }; const deleteTarget = (readablePageRecord as Record | null | undefined) ?? { id: pureRecordId }; - const copy = recordDelete.confirmCopy(deleteDeps, { record: deleteTarget }); + // objectstack#28472 — and names the objects the delete cascades into. + const cascadeLabels = await resolveCascadeDeleteLabels(dataSource, objectName!, (name) => + objectLabel(objects.find((o: any) => o.name === name) ?? { name, label: name })); + const copy = recordDelete.confirmCopy({ ...deleteDeps, cascadeLabels }, { 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 diff --git a/ui/packages/core/src/actions/recordDelete.ts b/ui/packages/core/src/actions/recordDelete.ts index 5dc54382264..55461e316d3 100644 --- a/ui/packages/core/src/actions/recordDelete.ts +++ b/ui/packages/core/src/actions/recordDelete.ts @@ -51,6 +51,16 @@ * the counterpart of the record-save toasts; a record with nothing nameable * keeps the object-label toast. * + * ## The confirmation names what the delete cascades into (objectstack#28472) + * + * A delete can also delete child records (a `master_detail` child, a relation + * declared `deleteBehavior: 'cascade'`). The platform serves which objects, by + * name, beside the object read (`cascadeDeleteObjects`, carried to the client + * by `attachCascadeDeleteObjects`); the host resolves those names to labels in + * the user's language and hands them in as `cascadeLabels`, and the body gains + * one sentence naming them. No labels (an empty or absent served list) adds + * nothing, so the body is today's. ⛔ Nothing here derives the cascade itself. + * * ## The result shape is the action runner's, on purpose * * `run` returns what a registered `delete` handler returns, because the console @@ -154,15 +164,24 @@ export const recordDelete = Object.freeze({ deps: Pick & { /** The object definition, so the name follows its declared `nameField`. */ objectDef?: unknown; + /** + * The labels of the objects the delete cascades into, resolved by the + * host from the served `cascadeDeleteObjects` (objectstack#28472). Empty + * or absent adds nothing to the body. + */ + cascadeLabels?: readonly string[]; }, target: RecordDeleteConfirmTarget, ): RecordDeleteConfirmCopy { const { t, label } = deps; const confirmText = t('objectActions.deleteConfirmButton'); + const cascade = deps.cascadeLabels && deps.cascadeLabels.length > 0 + ? ` ${t('objectActions.deleteCascade', { objects: deps.cascadeLabels.join(', ') })}` + : ''; if ('count' in target) { return { title: t('objectActions.bulkDeleteConfirmTitle', { count: target.count, label }), - message: t('console.objectView.bulkDeleteConfirm', { count: target.count }), + message: `${t('console.objectView.bulkDeleteConfirm', { count: target.count })}${cascade}`, confirmText, }; } @@ -171,7 +190,7 @@ export const recordDelete = Object.freeze({ label, name: getRecordDisplayName(deps.objectDef, target.record), }), - message: recordDelete.confirmText(deps), + message: `${recordDelete.confirmText(deps)}${cascade}`, confirmText, }; }, diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index 73a0776e029..d6afd8f5c1c 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -60,6 +60,9 @@ export * from './utils/column-sortability.js'; // actions the installation switched off, stamped by the data layer and asked // by every action gate beside the capability check. export * from './utils/action-activation.js'; +// [objectstack#28472] The served delete cascade: the objects a delete of this +// object also deletes, carried by the data layer to the delete confirmation. +export * from './utils/cascade-delete-objects.js'; // [objectstack#28211] A list view's `kanban.lanes` as the `object-kanban` // node's `columns`: the one mapping both producers of that node share. export * from './utils/kanban-declared-lanes.js'; diff --git a/ui/packages/core/src/utils/cascade-delete-objects.ts b/ui/packages/core/src/utils/cascade-delete-objects.ts new file mode 100644 index 00000000000..4662e7055db --- /dev/null +++ b/ui/packages/core/src/utils/cascade-delete-objects.ts @@ -0,0 +1,63 @@ +/** + * ObjectUI — served delete cascade (consumer side) + * 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. + */ + +/** + * The objects a delete of this object also deletes, as the platform serves + * them (objectstack#28001, ruling B; ui half objectstack#28472). + * + * ## What the platform serves + * + * The by-name object read (`GET /api/v1/meta/object/NAME`, and the app bundle's + * object entries) serves `cascadeDeleteObjects` in its ENVELOPE, beside `item`: + * the object names a delete reaches through the cascade rule the engine runs, + * each once, in the order the delete reaches it. `[]` means nothing cascades. + * The key is ABSENT when no engine answered (a draft read, the list read, an + * older backend), and absence never means "nothing cascades". + * + * ## How it reaches a confirmation + * + * The data layer re-attaches the list to the unwrapped schema under + * {@link CASCADE_DELETE_OBJECTS_KEY} ({@link attachCascadeDeleteObjects}). The + * key is a non-enumerable SYMBOL, so the list is invisible to + * `JSON.stringify`, `Object.keys` and a spread: a schema handed back to a + * metadata write never carries it. The delete confirmation reads it with + * {@link readCascadeDeleteObjects}. + * + * ⛔ No client copy of the cascade rule: nothing here walks relationships. The + * served list is the one answer. + */ + +/** The symbol the served cascade list rides under on a client-side object schema. */ +export const CASCADE_DELETE_OBJECTS_KEY: unique symbol = Symbol.for('objectui.cascadeDeleteObjects'); + +/** + * Stamp the served `cascadeDeleteObjects` onto the client-side object schema. + * In place, and returns `schema`. A value that is not a list of names stamps + * nothing, so an envelope without the key leaves the schema as it was. + */ +export function attachCascadeDeleteObjects(schema: T, served: unknown): T { + if (!Array.isArray(served) || !schema || typeof schema !== 'object') return schema; + const names = served.filter((n): n is string => typeof n === 'string' && n !== ''); + Object.defineProperty(schema, CASCADE_DELETE_OBJECTS_KEY, { + value: Object.freeze(names), + enumerable: false, + configurable: true, + writable: true, + }); + return schema; +} + +/** + * The served cascade list off a client-side object schema. `undefined` means + * no answer was served, not that nothing cascades; `[]` means nothing cascades. + */ +export function readCascadeDeleteObjects(schema: unknown): readonly string[] | undefined { + if (!schema || typeof schema !== 'object') return undefined; + const carried = (schema as Record)[CASCADE_DELETE_OBJECTS_KEY]; + return Array.isArray(carried) ? (carried as readonly string[]) : undefined; +} diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 6f8a1e2b172..883b3007fde 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -82,6 +82,7 @@ import type { import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types'; import { attachObjectSortability, + attachCascadeDeleteObjects, markSwitchedOffActions, convertFiltersToAST, emulateBatchTransaction, @@ -5745,6 +5746,11 @@ export class ObjectStackAdapter implements DataSource { // entries under a symbol (`markSwitchedOffActions`), so every action gate // reads it and no metadata write ever carries it. markSwitchedOffActions(item, data && typeof data === 'object' ? (data as any).disabledActions : undefined); + // [objectstack#28472] …and the envelope's `cascadeDeleteObjects`, the + // objects a delete of this one also deletes: carried under a symbol + // (`attachCascadeDeleteObjects`) for the delete confirmation. Absent stays + // absent, which the confirmation reads as "no answer", never as "none". + attachCascadeDeleteObjects(item, data && typeof data === 'object' ? (data as any).cascadeDeleteObjects : undefined); return attachObjectSortability(item, data && typeof data === 'object' ? (data as any).sortability : undefined); } diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 385b109200d..31995007755 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3572,6 +3572,7 @@ const ar = { bulkDeleteConfirmTitle_many: "حذف {{count}} من سجلات {{label}}؟", bulkDeleteConfirmTitle_other: "حذف {{count}} من سجلات {{label}}؟", deleteConfirmButton: "حذف", + deleteCascade: "سيؤدي هذا أيضًا إلى حذف السجلات المرتبطة في: {{objects}}.", bulkDeleteSuccess: "تم حذف {{count}} سجل(ات) {{label}}", bulkDeleteSuccess_zero: "تم حذف {{count}} من سجلات {{label}}", bulkDeleteSuccess_one: "تم حذف {{count}} من سجلات {{label}}", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 344607ba79f..e3bcb069bde 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -3303,6 +3303,7 @@ const de = { bulkDeleteConfirmTitle_one: "{{count}} {{label}}-Datensatz löschen?", bulkDeleteConfirmTitle_other: "{{count}} {{label}}-Datensätze löschen?", deleteConfirmButton: "Löschen", + deleteCascade: "Dabei werden auch die zugehörigen Datensätze gelöscht in: {{objects}}.", 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/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index aa4b7e5ac32..c81b3097cc3 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -4068,6 +4068,7 @@ const en = { bulkDeleteConfirmTitle_one: 'Delete {{count}} {{label}} record?', bulkDeleteConfirmTitle_other: 'Delete {{count}} {{label}} records?', deleteConfirmButton: 'Delete', + deleteCascade: 'This also deletes their related records in: {{objects}}.', bulkDeleteSuccess: 'Deleted {{count}} {{label}} records', bulkDeleteSuccess_one: 'Deleted {{count}} {{label}} record', bulkDeleteSuccess_other: 'Deleted {{count}} {{label}} records', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 9aa630326c0..c29c501ee69 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -3371,6 +3371,7 @@ const es = { bulkDeleteConfirmTitle_many: "¿Eliminar {{count}} de registros de {{label}}?", bulkDeleteConfirmTitle_other: "¿Eliminar {{count}} registros de {{label}}?", deleteConfirmButton: "Eliminar", + deleteCascade: "Esto también elimina los registros relacionados en: {{objects}}.", bulkDeleteSuccess: "{{count}} registros de {{label}} eliminados", bulkDeleteSuccess_one: "{{count}} registro de {{label}} eliminado", bulkDeleteSuccess_many: "{{count}} de registros de {{label}} eliminados", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index a59291c14c8..fa65c0ceb4c 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -3367,6 +3367,7 @@ const fr = { bulkDeleteConfirmTitle_many: "Supprimer {{count}} d'enregistrements {{label}} ?", bulkDeleteConfirmTitle_other: "Supprimer {{count}} enregistrements {{label}} ?", deleteConfirmButton: "Supprimer", + deleteCascade: "Cela supprime aussi les enregistrements liés dans : {{objects}}.", bulkDeleteSuccess: "{{count}} enregistrement(s) {{label}} supprimé(s)", bulkDeleteSuccess_one: "{{count}} enregistrement {{label}} supprimé", bulkDeleteSuccess_many: "{{count}} d'enregistrements {{label}} supprimés", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f3998aff5be..618c5031d48 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -3305,6 +3305,7 @@ const ja = { bulkDeleteConfirmTitle_one: "{{count}} 件の {{label}} レコードを削除しますか?", bulkDeleteConfirmTitle_other: "{{count}} 件の {{label}} レコードを削除しますか?", deleteConfirmButton: "削除", + deleteCascade: "関連する次のレコードも削除されます: {{objects}}。", bulkDeleteSuccess: "{{count}} 件の {{label}} レコードを削除しました", bulkDeleteSuccess_one: "{{count}} 件の {{label}} レコードを削除しました", bulkDeleteSuccess_other: "{{count}} 件の {{label}} レコードを削除しました", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 499c0df2ec3..c27dee39e6f 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -3302,6 +3302,7 @@ const ko = { bulkDeleteConfirmTitle_one: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", bulkDeleteConfirmTitle_other: "{{count}}개의 {{label}} 레코드를 삭제하시겠습니까?", deleteConfirmButton: "삭제", + deleteCascade: "다음의 관련 레코드도 함께 삭제됩니다: {{objects}}.", bulkDeleteSuccess: "{{count}}개의 {{label}} 레코드 삭제됨", bulkDeleteSuccess_one: "{{count}}개의 {{label}} 레코드 삭제됨", bulkDeleteSuccess_other: "{{count}}개의 {{label}} 레코드 삭제됨", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index a0f79fbee23..a769b138e8b 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -3366,6 +3366,7 @@ const pt = { bulkDeleteConfirmTitle_many: "Excluir {{count}} de registros de {{label}}?", bulkDeleteConfirmTitle_other: "Excluir {{count}} registros de {{label}}?", deleteConfirmButton: "Excluir", + deleteCascade: "Isso também exclui os registros relacionados em: {{objects}}.", 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/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index d5ee57a8bbb..50b6d937510 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -3448,6 +3448,7 @@ const ru = { bulkDeleteConfirmTitle_many: "Удалить {{count}} записей {{label}}?", bulkDeleteConfirmTitle_other: "Удалить {{count}} записи {{label}}?", deleteConfirmButton: "Удалить", + deleteCascade: "Также будут удалены связанные записи в: {{objects}}.", bulkDeleteSuccess: "{{count}} записей {{label}} удалено", bulkDeleteSuccess_one: "Удалена {{count}} запись {{label}}", bulkDeleteSuccess_few: "Удалено {{count}} записи {{label}}", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 961a73eab77..7d1cf817420 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -3519,6 +3519,7 @@ const zh = { bulkDeleteConfirmTitle_one: '删除 {{count}} 条 {{label}} 记录?', bulkDeleteConfirmTitle_other: '删除 {{count}} 条 {{label}} 记录?', deleteConfirmButton: '删除', + deleteCascade: '同时会删除以下对象中的相关记录:{{objects}}。', bulkDeleteSuccess: '已删除 {{count}} 条 {{label}} 记录', bulkDeleteSuccess_one: '已删除 {{count}} 条 {{label}} 记录', bulkDeleteSuccess_other: '已删除 {{count}} 条 {{label}} 记录', From 0bb51f4827eca4971a2cbe540f40ea49ba3ed449 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:04:28 +0000 Subject: [PATCH 2/5] test(console): pin the cascade line on the row and record-page delete confirmations Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvGtYbnKXjz3iks1AW --- ...ObjectActions.deleteCascade-28472.test.tsx | 138 +++++++++++++ ...ordDetailView.deleteCascade-28472.test.tsx | 188 ++++++++++++++++++ .../recordDelete.cascade-28472.test.ts | 99 +++++++++ ...tSchema.cascadeDeleteObjects-28472.test.ts | 63 ++++++ 4 files changed, 488 insertions(+) create mode 100644 ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.deleteCascade-28472.test.tsx create mode 100644 ui/packages/core/src/actions/__tests__/recordDelete.cascade-28472.test.ts create mode 100644 ui/packages/data-objectstack/src/getObjectSchema.cascadeDeleteObjects-28472.test.ts diff --git a/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx new file mode 100644 index 00000000000..b5cdee126f1 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx @@ -0,0 +1,138 @@ +/** + * 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#28472 — the console list's row Delete (and the selection's) + * names the objects the delete cascades into, from the served + * `cascadeDeleteObjects`, by label. + * + * The list's `objectDef` comes from the list read, which never serves the key, + * so the hook reads the by-name schema the adapter carries it on + * (`dataSource.getObjectSchema`). The data source here hands back exactly what + * the adapter would: a schema with the served list attached under its symbol. + * Strings are read from the packs by KEY under a real `I18nProvider`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { attachCascadeDeleteObjects, type ConfirmationHandler } from '@object-ui/core'; + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }), +})); + +import { useObjectActions } from '../useObjectActions'; + +type Pack = { + objectActions: { deleteConfirm: string; deleteCascade: string }; + console: { objectView: { bulkDeleteConfirm_other: string } }; +}; +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) => String(vars[k])); + +const LEAD = { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text' } } }; +const LABELS: Record = { crm_campaign_member: 'Campaign Member' }; +const objectLabelOf = (name: string) => LABELS[name] ?? name; + +function Harness({ dataSource, onConfirm }: { dataSource: any; onConfirm: ConfirmationHandler }) { + const actions = useObjectActions({ + objectName: LEAD.name, + objectLabel: LEAD.label, + objectDef: LEAD, + dataSource, + onConfirm, + objectLabelOf, + }); + return ( + <> + + + + ); +} + +/** Mount with a data source whose by-name schema read answers `served`. */ +function mount(served: unknown, language: 'en' | 'zh' = 'en') { + const getObjectSchema = vi.fn(async () => + served === 'throw' + ? Promise.reject(new Error('offline')) + : attachCascadeDeleteObjects(structuredClone(LEAD), served), + ); + const dataSource = { delete: vi.fn(async () => ({})), getObjectSchema }; + const onConfirm = vi.fn(async () => false); + render( + + + + + , + ); + return { onConfirm, getObjectSchema, dataSource }; +} + +async function askedMessage(onConfirm: ReturnType, button: string): Promise { + fireEvent.click(screen.getByText(button)); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + return onConfirm.mock.calls[0][0] as string; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('console row Delete names the cascade objects (objectstack#28472)', () => { + it('a served list of one object adds one line naming its label', async () => { + const { onConfirm, getObjectSchema } = mount(['crm_campaign_member']); + const message = await askedMessage(onConfirm, 'row-delete'); + expect(getObjectSchema).toHaveBeenCalledWith('crm_lead'); + expect(message).toBe( + `${EN.objectActions.deleteConfirm} ${fill(EN.objectActions.deleteCascade, { objects: 'Campaign Member' })}`, + ); + expect(message).not.toContain('crm_campaign_member'); + }); + + it('the same line in zh', async () => { + const { onConfirm } = mount(['crm_campaign_member'], 'zh'); + const message = await askedMessage(onConfirm, 'row-delete'); + expect(ZH.objectActions.deleteCascade).not.toBe(EN.objectActions.deleteCascade); + expect(message).toBe( + `${ZH.objectActions.deleteConfirm} ${fill(ZH.objectActions.deleteCascade, { objects: 'Campaign Member' })}`, + ); + }); + + it('a served empty list adds nothing', async () => { + const { onConfirm } = mount([]); + expect(await askedMessage(onConfirm, 'row-delete')).toBe(EN.objectActions.deleteConfirm); + }); + + it('no served list adds nothing and keeps today\'s text', async () => { + const { onConfirm } = mount(undefined); + expect(await askedMessage(onConfirm, 'row-delete')).toBe(EN.objectActions.deleteConfirm); + }); + + it('a failed schema read adds nothing and still asks', async () => { + const { onConfirm, dataSource } = mount('throw'); + expect(await askedMessage(onConfirm, 'row-delete')).toBe(EN.objectActions.deleteConfirm); + expect(dataSource.delete).not.toHaveBeenCalled(); + }); + + it('a selection gains the same line after the batch question', async () => { + const { onConfirm } = mount(['crm_campaign_member']); + const message = await askedMessage(onConfirm, 'bulk-delete'); + expect(message).toBe( + `${fill(EN.console.objectView.bulkDeleteConfirm_other, { count: 2 })} ${fill(EN.objectActions.deleteCascade, { objects: 'Campaign Member' })}`, + ); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.deleteCascade-28472.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.deleteCascade-28472.test.tsx new file mode 100644 index 00000000000..2108a30b846 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.deleteCascade-28472.test.tsx @@ -0,0 +1,188 @@ +/** + * 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#28472 — the record page's Delete confirmation names the objects + * the delete cascades into, from the served `cascadeDeleteObjects`, by label. + * + * The page's `objectDef` is the list read's entry, which never serves the key, + * so the page reads the by-name schema the adapter carries it on + * (`dataSource.getObjectSchema`); the data source here answers exactly what + * the adapter would. The REAL `ActionConfirmDialog` opens, and its body is + * read. Strings are read from the packs by KEY. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, 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 { builtInLocales } from '@object-ui/i18n/locales'; +import { attachCascadeDeleteObjects } from '@object-ui/core'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT_NAME = 'crm_lead'; +const RECORD_ID = 'rec-lead-1'; + +type Pack = { + detail: { delete: string; moreActions: string }; + objectActions: { deleteConfirm: string; deleteCascade: string }; +}; +const EN = builtInLocales.en as unknown as Pack; +const fill = (template: string, vars: Record) => + template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => vars[k]); + +const LEAD = { + name: OBJECT_NAME, + label: 'Lead', + managedBy: 'platform', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, +}; +const CAMPAIGN_MEMBER = { + name: 'crm_campaign_member', + label: 'Campaign Member', + managedBy: 'platform', + fields: { name: { type: 'text', label: 'Name' } }, +}; +const OBJECTS = [LEAD, CAMPAIGN_MEMBER]; + +const METADATA = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as any; + +/** A data source whose by-name schema read answers `served`, as the adapter would. */ +function makeDataSource(served: unknown) { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'Ada' })), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async (name: string) => + name === OBJECT_NAME ? attachCascadeDeleteObjects(structuredClone(LEAD), served) : null, + ), + } as any; +} + +async function openDeleteConfirm(served: unknown): Promise { + const dataSource = makeDataSource(served); + render( + + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + /> + + + , + ); + await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled()); + await waitFor(() => expect(document.querySelector('[role="toolbar"]')).toBeTruthy()); + const trigger = screen.getByRole('button', { name: EN.detail.moreActions }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + const item = screen + .getAllByRole('menuitem') + .find((el) => (el.textContent ?? '').trim() === EN.detail.delete); + expect(item).toBeTruthy(); + fireEvent.click(item!); + const dialog = await screen.findByRole('alertdialog'); + expect(dataSource.delete).not.toHaveBeenCalled(); + return dialog; +} + +/** The dialog body: the description under the title. */ +function body(dialog: HTMLElement): string { + const heading = within(dialog).getByRole('heading'); + const description = heading.parentElement?.querySelector('p'); + return (description?.textContent ?? '').trim(); +} + +beforeEach(() => { + cleanup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('record page Delete names the cascade objects (objectstack#28472)', () => { + it('a served list of one object adds one line naming its label', async () => { + const dialog = await openDeleteConfirm(['crm_campaign_member']); + expect(body(dialog)).toBe( + `${EN.objectActions.deleteConfirm} ${fill(EN.objectActions.deleteCascade, { objects: 'Campaign Member' })}`, + ); + }); + + it('a served empty list adds nothing', async () => { + const dialog = await openDeleteConfirm([]); + expect(body(dialog)).toBe(EN.objectActions.deleteConfirm); + }); + + it('no served list adds nothing and keeps today\'s text', async () => { + const dialog = await openDeleteConfirm(undefined); + expect(body(dialog)).toBe(EN.objectActions.deleteConfirm); + }); +}); diff --git a/ui/packages/core/src/actions/__tests__/recordDelete.cascade-28472.test.ts b/ui/packages/core/src/actions/__tests__/recordDelete.cascade-28472.test.ts new file mode 100644 index 00000000000..574715a147f --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/recordDelete.cascade-28472.test.ts @@ -0,0 +1,99 @@ +/** + * 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#28472 — the delete confirmation names the objects the delete + * cascades into, from labels the host resolved off the served + * `cascadeDeleteObjects`; and the carried-key pair that brings that list from + * the adapter to the host. + * + * The translator echoes the key plus its variables, so each assertion names + * the copy it expects without depending on a pack. + */ + +import { describe, it, expect } from 'vitest'; +import { recordDelete } from '../recordDelete'; +import { + attachCascadeDeleteObjects, + readCascadeDeleteObjects, + CASCADE_DELETE_OBJECTS_KEY, +} from '../../utils/cascade-delete-objects'; + +const t = (key: string, options?: Record) => { + const vars = { ...(options ?? {}) }; + delete vars.defaultValue; + const suffix = Object.keys(vars).length ? ` ${JSON.stringify(vars)}` : ''; + return `${key}${suffix}`; +}; + +const deps = { objectName: 'crm_lead', t, label: 'Lead' }; +const ROW = { id: 'l1', name: 'Ada' }; + +describe('recordDelete.confirmCopy names the cascade (objectstack#28472)', () => { + it('one record: the body gains one line naming each cascade label', () => { + const copy = recordDelete.confirmCopy( + { ...deps, cascadeLabels: ['Campaign Member'] }, + { record: ROW }, + ); + expect(copy.message).toBe( + 'objectActions.deleteConfirm objectActions.deleteCascade {"objects":"Campaign Member"}', + ); + }); + + it('several labels are listed in the served order', () => { + const copy = recordDelete.confirmCopy( + { ...deps, cascadeLabels: ['Campaign Member', 'Lead Score'] }, + { record: ROW }, + ); + expect(copy.message).toContain('{"objects":"Campaign Member, Lead Score"}'); + }); + + it('a batch gains the same line after the batch question', () => { + const copy = recordDelete.confirmCopy( + { ...deps, cascadeLabels: ['Campaign Member'] }, + { count: 2 }, + ); + expect(copy.message).toBe( + 'console.objectView.bulkDeleteConfirm {"count":2} objectActions.deleteCascade {"objects":"Campaign Member"}', + ); + }); + + it('an empty list adds nothing: the body is the plain question', () => { + const copy = recordDelete.confirmCopy({ ...deps, cascadeLabels: [] }, { record: ROW }); + expect(copy.message).toBe('objectActions.deleteConfirm'); + }); + + it('no list adds nothing and keeps the copy exactly as before', () => { + const withKey = recordDelete.confirmCopy({ ...deps, cascadeLabels: undefined }, { record: ROW }); + const without = recordDelete.confirmCopy(deps, { record: ROW }); + expect(withKey).toEqual(without); + expect(without.message).toBe('objectActions.deleteConfirm'); + }); +}); + +describe('attachCascadeDeleteObjects / readCascadeDeleteObjects (objectstack#28472)', () => { + it('carries the served list under a non-enumerable symbol', () => { + const schema = attachCascadeDeleteObjects({ name: 'crm_lead' }, ['crm_campaign_member']); + expect(readCascadeDeleteObjects(schema)).toEqual(['crm_campaign_member']); + expect(Object.keys(schema)).toEqual(['name']); + expect(JSON.stringify(schema)).toBe('{"name":"crm_lead"}'); + expect({ ...schema }).not.toHaveProperty(String(CASCADE_DELETE_OBJECTS_KEY)); + }); + + it('keeps an empty list as empty, distinct from absent', () => { + expect(readCascadeDeleteObjects(attachCascadeDeleteObjects({}, []))).toEqual([]); + expect(readCascadeDeleteObjects(attachCascadeDeleteObjects({}, undefined))).toBeUndefined(); + expect(readCascadeDeleteObjects({})).toBeUndefined(); + expect(readCascadeDeleteObjects(null)).toBeUndefined(); + }); + + it('drops entries that are not object names and refuses a non-list', () => { + expect(readCascadeDeleteObjects(attachCascadeDeleteObjects({}, ['a', 3, '', null, 'b']))).toEqual(['a', 'b']); + expect(readCascadeDeleteObjects(attachCascadeDeleteObjects({}, 'a'))).toBeUndefined(); + }); +}); diff --git a/ui/packages/data-objectstack/src/getObjectSchema.cascadeDeleteObjects-28472.test.ts b/ui/packages/data-objectstack/src/getObjectSchema.cascadeDeleteObjects-28472.test.ts new file mode 100644 index 00000000000..a54665db41a --- /dev/null +++ b/ui/packages/data-objectstack/src/getObjectSchema.cascadeDeleteObjects-28472.test.ts @@ -0,0 +1,63 @@ +/** + * objectstack#28472 — the ENVELOPE's `cascadeDeleteObjects` survives the unwrap. + * + * `GET /api/v1/meta/object/:name` serves the objects a delete of this object + * also deletes BESIDE `item`. The adapter's unwrap returns `item` alone, so it + * re-attaches the list under a symbol first, the seam `sortability` and + * `disabledActions` cross the same way. Absent stays absent: it means no + * engine answered, never that nothing cascades. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { readCascadeDeleteObjects } from '@object-ui/core'; +import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index'; + +const LEAD = { name: 'crm_lead', fields: { name: { type: 'text' } } }; + +function adapterOver(body: unknown) { + // A fresh object per response, as a real `res.json()` parses. + const fresh = () => JSON.parse(JSON.stringify(body)); + const fetchImpl = vi.fn(async (url: any) => { + const u = String(url); + if (u.includes('/api/v1/discovery')) { + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any; + } + if (u.includes('/api/v1/meta/object/')) { + return { ok: true, status: 200, statusText: 'OK', json: async () => fresh() } as any; + } + return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any; + }); + return new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any }); +} + +describe('getObjectSchema carries the envelope cascadeDeleteObjects (objectstack#28472)', () => { + beforeEach(() => clearSharedDiscoveryCache()); + + it('carries the served list onto the schema, off its enumerable keys', async () => { + const schema: any = await adapterOver({ + success: true, + data: { type: 'object', name: 'crm_lead', item: LEAD, cascadeDeleteObjects: ['crm_campaign_member'] }, + }).getObjectSchema('crm_lead'); + expect(schema.name).toBe('crm_lead'); + expect(readCascadeDeleteObjects(schema)).toEqual(['crm_campaign_member']); + // Never a document property: a schema written back carries no such key. + expect(Object.keys(schema)).not.toContain('cascadeDeleteObjects'); + expect(JSON.stringify(schema)).not.toContain('crm_campaign_member'); + }); + + it('carries an empty list as empty: nothing cascades', async () => { + const schema: any = await adapterOver({ + success: true, + data: { type: 'object', name: 'crm_lead', item: LEAD, cascadeDeleteObjects: [] }, + }).getObjectSchema('crm_lead'); + expect(readCascadeDeleteObjects(schema)).toEqual([]); + }); + + it('carries nothing when the envelope has no `cascadeDeleteObjects`', async () => { + const schema: any = await adapterOver({ + success: true, + data: { type: 'object', name: 'crm_lead', item: LEAD }, + }).getObjectSchema('crm_lead'); + expect(readCascadeDeleteObjects(schema)).toBeUndefined(); + }); +}); From e1ded0b876a90c0dd71678f03234dbe2a86cbbd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:09:40 +0000 Subject: [PATCH 3/5] chore: changeset for the delete confirmation cascade line Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvGtYbnKXjz3iks1AW --- .../28472-delete-confirm-cascade-objects.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/28472-delete-confirm-cascade-objects.md diff --git a/.changeset/28472-delete-confirm-cascade-objects.md b/.changeset/28472-delete-confirm-cascade-objects.md new file mode 100644 index 00000000000..6a0b1cd5676 --- /dev/null +++ b/.changeset/28472-delete-confirm-cascade-objects.md @@ -0,0 +1,17 @@ +--- +'@object-ui/core': patch +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): the row and record-page Delete confirmations name the objects a delete will cascade into, from the platform's served `cascadeDeleteObjects` + +Clause-②: yes (widening) +Metadata-protocol: none + +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: + +- **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. +- **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. +- **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 exports `resolveCascadeDeleteLabels`. From 2c017197a5f9700c3544c669e5ad84aaaba793f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:57:52 +0000 Subject: [PATCH 4/5] docs(changeset): say what app-shell publishes for the cascade line Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvGtYbnKXjz3iks1AW --- .changeset/28472-delete-confirm-cascade-objects.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/28472-delete-confirm-cascade-objects.md b/.changeset/28472-delete-confirm-cascade-objects.md index 6a0b1cd5676..bafa5757d42 100644 --- a/.changeset/28472-delete-confirm-cascade-objects.md +++ b/.changeset/28472-delete-confirm-cascade-objects.md @@ -14,4 +14,4 @@ A delete can also delete child records, for example a lead's campaign membership - **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. - **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. -- **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 exports `resolveCascadeDeleteLabels`. +- **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. From 4e795d9e238c0ac2ce7b47f66598ce1fb38f1947 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 00:15:35 +0000 Subject: [PATCH 5/5] fix(ui-core): list the spec ActionSchema's overrides key in the action key inventory Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvGtYbnKXjz3iks1AW --- ui/packages/core/src/actions/actionKeys.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/packages/core/src/actions/actionKeys.ts b/ui/packages/core/src/actions/actionKeys.ts index 0ab0e262d21..bd2632d86e1 100644 --- a/ui/packages/core/src/actions/actionKeys.ts +++ b/ui/packages/core/src/actions/actionKeys.ts @@ -305,6 +305,12 @@ export const SPEC_ACTION_KEYS = [ // runner reads it as the success toast's first rung, ahead of // `successMessage`. 'outcomeMessages', + // Added to `ActionSchema` by objectstack#28467 (ADR-0146): the override + // declaration `{ package, basedOn }` every overridable metadata type carries. + // Listed for the reason `description` is — this array restates the spec's + // declared keys, so an action carrying it is not reported as having an + // unknown key. `ActionDef` does NOT declare it and the runner does not read it. + 'overrides', 'params', 'patch', // Added to `ActionSchema` by objectstack#27946: the pressed-state predicate a