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/28472-delete-confirm-cascade-objects.md
Original file line number Diff line number Diff line change
@@ -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 resolves the labels itself.
Original file line number Diff line number Diff line change
@@ -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<string, string | number>) =>
template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(vars[k]));

const LEAD = { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text' } } };
const LABELS: Record<string, string> = { 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 (
<>
<button onClick={() => void actions.deleteRecord('l1', { id: 'l1', name: 'Ada' })}>row-delete</button>
<button onClick={() => void actions.deleteRecords([{ id: 'a' }, { id: 'b' }])}>bulk-delete</button>
</>
);
}

/** 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<ConfirmationHandler>(async () => false);
render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<MemoryRouter initialEntries={['/apps/crm/crm_lead']}>
<Harness dataSource={dataSource} onConfirm={onConfirm} />
</MemoryRouter>
</I18nProvider>,
);
return { onConfirm, getObjectSchema, dataSource };
}

async function askedMessage(onConfirm: ReturnType<typeof vi.fn>, button: string): Promise<string> {
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' })}`,
);
});
});
42 changes: 38 additions & 4 deletions ui/packages/app-shell/src/hooks/useObjectActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
import { toast } from 'sonner';
import {
recordDelete,
readCascadeDeleteObjects,
type ActionDef,
type ActionResult,
type ConfirmationHandler,
Expand All @@ -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<unknown> } | null | undefined,
objectName: string,
labelOf: (objectName: string) => string = (name) => name,
): Promise<string[] | undefined> {
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -75,6 +106,7 @@ export function useObjectActions({
onRefresh,
onConfirm,
onToast,
objectLabelOf,
}: ObjectActionConfig): ObjectActions {
const navigate = useNavigate();
const { appName } = useParams();
Expand Down Expand Up @@ -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' };
Expand All @@ -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<string, unknown>[]): Promise<ActionResult> => {
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(
Expand Down
6 changes: 6 additions & 0 deletions ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,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
Expand All @@ -724,6 +729,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,
Expand Down
Loading
Loading