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
11 changes: 11 additions & 0 deletions .changeset/29807-field-panel-delete-behavior.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(app-shell): the Studio object designer's field panel offers a Delete Behavior row on lookup, user and master_detail fields

Clause-②: no
Metadata-protocol: none

The field panel in the Studio object designer now draws a Delete Behavior select, so an author can set or clear `deleteBehavior` without editing raw metadata. It follows the generic `field` form's rows for the same key: a `lookup` or `user` field offers Set Null, Cascade and Restrict, and a `master_detail` field offers Cascade and Restrict only, because the spec refuses an authored `set_null` there. The leading "— None —" item removes the key, so the field declares nothing (the spec default on a `lookup`; no part in the delete walk on a `user` field). Changing a field to a type without the row removes the key, and changing it to `master_detail` removes an authored `set_null`. The labels are new Studio namespace keys (`designer.field.deleteBehavior.*`) in English and Chinese.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FieldSchema } from '@objectstack/spec/data';

// The inspector loads the object list (published + drafts) for the lookup
Expand Down Expand Up @@ -32,6 +33,7 @@ vi.mock('../previews/useObjectFields', () => ({

import { ObjectFieldInspector } from './ObjectFieldInspector';
import { __setCelFormulaLoader } from '../celAuthoring';
import { t } from '../i18n';

afterEach(() => {
cleanup();
Expand Down Expand Up @@ -667,3 +669,126 @@ describe('ObjectFieldInspector — summary roll-up editor', () => {
expect(screen.queryByText('Filter 1')).not.toBeInTheDocument();
});
});

describe('ObjectFieldInspector — delete behavior (objectstack#29807)', () => {
// Radix Select reads pointer capture and scrollIntoView, which jsdom lacks.
const proto = Element.prototype as unknown as Record<string, unknown>;
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture']) {
if (!proto[m]) proto[m] = m === 'hasPointerCapture' ? () => false : () => {};
}
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};

type Def = Record<string, unknown>;
const LABEL = t('designer.field.deleteBehavior', 'en-US');
const NONE = t('engine.form.none', 'en-US');
const SET_NULL = t('designer.field.deleteBehavior.set_null', 'en-US');
const CASCADE = t('designer.field.deleteBehavior.cascade', 'en-US');
const RESTRICT = t('designer.field.deleteBehavior.restrict', 'en-US');
const LOOKUP: Def = { type: 'lookup', label: 'Parent', reference: 'account' };
const USER: Def = { type: 'user', label: 'Assignee' };
const MASTER: Def = { type: 'master_detail', label: 'Master', reference: 'account' };
const has = (o: object, key: string) => Object.prototype.hasOwnProperty.call(o, key);

/** A host that feeds each patch back into the draft, as Studio's editor does. */
function renderStateful(def: Def) {
const onPatch = vi.fn();
function Host() {
const [draft, setDraft] = React.useState<Record<string, unknown>>({
name: 'ticket',
fields: { title: { type: 'text', label: 'Title' }, ref: def },
});
return (
<ObjectFieldInspector
type="object"
name="ticket"
draft={draft}
selection={{ kind: 'field', id: 'ref' }}
onPatch={(patch: Record<string, unknown>) => {
onPatch(patch);
setDraft((d) => ({ ...d, ...patch }));
}}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={'en-US'}
/>
);
}
render(<Host />);
/** The `ref` definition the editor's Save would persist after the last edit. */
const saved = () => (onPatch.mock.calls.at(-1)![0].fields as Record<string, Def>).ref;
return { onPatch, saved };
}

const control = () => screen.queryByRole('combobox', { name: LABEL });

async function optionNames(): Promise<string[]> {
await userEvent.click(control()!);
const list = await screen.findByRole('listbox');
return within(list).getAllByRole('option').map((o) => o.textContent ?? '');
}

async function pick(name: string) {
await userEvent.click(control()!);
const list = await screen.findByRole('listbox');
await userEvent.click(within(list).getByRole('option', { name }));
}

async function retypeTo(typeLabel: string) {
await userEvent.click(screen.getByRole('combobox', { name: t('designer.field.type', 'en-US') }));
const list = await screen.findByRole('listbox', { name: t('designer.field.type', 'en-US') });
await userEvent.click(within(list).getByRole('option', { name: typeLabel }));
}

it.each([
['lookup', LOOKUP, [NONE, SET_NULL, CASCADE, RESTRICT]],
['user', USER, [NONE, SET_NULL, CASCADE, RESTRICT]],
// The spec refuses an authored `set_null` on a master_detail.
['master_detail', MASTER, [NONE, CASCADE, RESTRICT]],
] as const)('draws the row on a %s field with the values the spec accepts there', async (_type, def, expected) => {
renderStateful(def);
expect(screen.getByTestId('field-delete-behavior')).toBeTruthy();
expect(await optionNames()).toEqual(expected);
});

it.each([
['text', { type: 'text', label: 'Title' }],
['tree', { type: 'tree', label: 'Parent' }],
] as const)('CONTROL: draws no row on a %s field', (_type, def) => {
renderStateful(def);
expect(screen.queryByTestId('field-delete-behavior')).toBeNull();
expect(control()).toBeNull();
});

it('picking a value writes it to the draft', async () => {
const { saved } = renderStateful(USER);
await pick(CASCADE);
expect(saved().deleteBehavior).toBe('cascade');
});

it('picking None removes the key outright (absent, never empty or null)', async () => {
const { saved } = renderStateful({ ...LOOKUP, deleteBehavior: 'restrict' });
await pick(NONE);
expect(has(saved(), 'deleteBehavior')).toBe(false);
expect(saved().reference).toBe('account');
});

it('a retype to text drops the key', async () => {
const { saved } = renderStateful({ ...LOOKUP, deleteBehavior: 'cascade' });
await retypeTo(t('engine.fieldType.text', 'en-US'));
expect(saved().type).toBe('text');
expect(has(saved(), 'deleteBehavior')).toBe(false);
});

it('a retype to master_detail drops set_null and keeps cascade', async () => {
const first = renderStateful({ ...LOOKUP, deleteBehavior: 'set_null' });
await retypeTo(t('engine.fieldType.master_detail', 'en-US'));
expect(first.saved().type).toBe('master_detail');
expect(has(first.saved(), 'deleteBehavior')).toBe(false);
cleanup();

const second = renderStateful({ ...LOOKUP, deleteBehavior: 'cascade' });
await retypeTo(t('engine.fieldType.master_detail', 'en-US'));
expect(second.saved().deleteBehavior).toBe('cascade');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,23 @@ function offersReferenceNameVisible(type: string): boolean {
return type === 'lookup' || type === 'user';
}

/**
* The `deleteBehavior` values the field panel offers on `type`: the rows the
* generic `field` form draws for the key (`field.form.ts`), so the two
* authoring surfaces agree. All three on a `lookup`, and on a `user` field,
* where a declared value takes effect when the user is deleted (objectstack#29253)
* and declaring nothing keeps the field out of the delete walk. Only
* `cascade` / `restrict` on a `master_detail`: `FieldSchema` refuses an
* authored `set_null` there, since a detail row cannot outlive its master.
* Empty on every other type, `tree` included: no row is drawn, and a retype
* to one drops the key.
*/
function offeredDeleteBehaviors(type: string): ReadonlyArray<string> {
if (type === 'lookup' || type === 'user') return ['set_null', 'cascade', 'restrict'];
if (type === 'master_detail') return ['cascade', 'restrict'];
return [];
}

/**
* objectui#11786 — what the object write guard holds this field for: its
* options (a choice type with no option source) or its target (a relationship
Expand Down Expand Up @@ -1098,7 +1115,15 @@ export function ObjectFieldInspector({
// The spec refuses `referenceNameVisible` off `lookup` / `user`, `false`
// included, so a retype to any other type removes the key outright.
const dropsNameVisible = 'referenceNameVisible' in def && !offersReferenceNameVisible(nextType);
if (keepsPicklist && !dropsKinds && !dropsNameVisible) {
// objectstack#29807 — the Delete Behavior row lives only where it is
// drawn, so a retype to a type without it removes the key, and a retype to
// `master_detail` removes an authored `set_null` the spec refuses there.
const nextDeleteBehaviors = offeredDeleteBehaviors(nextType);
const dropsDeleteBehavior =
'deleteBehavior' in def
&& (nextDeleteBehaviors.length === 0
|| (def.deleteBehavior === 'set_null' && !nextDeleteBehaviors.includes('set_null')));
if (keepsPicklist && !dropsKinds && !dropsNameVisible && !dropsDeleteBehavior) {
patchDef(requireMaster ? { type: nextType, required: true } : { type: nextType });
return;
}
Expand All @@ -1107,6 +1132,7 @@ export function ObjectFieldInspector({
if (!keepsPicklist) delete nextDef.picklist;
if (dropsKinds) nextDef.options = withoutOptionKinds(def.options as unknown[]);
if (dropsNameVisible) delete nextDef.referenceNameVisible;
if (dropsDeleteBehavior) delete nextDef.deleteBehavior;
const nextEntries = [...view.entries];
nextEntries[idx] = { ...entry, def: nextDef };
writeView({ shape: view.shape, entries: nextEntries });
Expand Down Expand Up @@ -1568,6 +1594,17 @@ export function ObjectFieldInspector({
)}
{/* A `user` field's target is always `sys_user`: no picker config, only this key. */}
{type === 'user' && nameVisibleToggle}
{offeredDeleteBehaviors(type).length > 0 && (
<DisplayPatchLock>
<DeleteBehaviorField
value={typeof def.deleteBehavior === 'string' ? def.deleteBehavior : ''}
offered={offeredDeleteBehaviors(type)}
onCommit={(v) => (v ? patchDef({ deleteBehavior: v }) : unsetDefKey('deleteBehavior'))}
disabled={locked}
locale={locale}
/>
</DisplayPatchLock>
)}
{type === 'formula' && (
<DisplayPatchLock>
<CelPredicateField
Expand Down Expand Up @@ -2590,6 +2627,50 @@ function ReferenceNameVisibleField({
);
}

/* ─────────────── Delete behavior (deleteBehavior) ─────────────── */

/**
* objectstack#29807 — what deleting the referenced record does to this one.
* The label, help text and the `master_detail` option labels are the generic
* `field` form's rows for the same key. The leading "— None —" item writes the
* key as ABSENT (`unsetDefKey`), never `''` or `null`: "declares nothing" is
* its own outcome (the spec default on a `lookup`, staying out of the delete
* walk on a `user` field), not a blank value. A stored value the type does not
* offer (`set_null` authored on a `master_detail`) is drawn flagged by
* {@link InspectorSelectField}, so the author sees what the save refuses.
*/
function DeleteBehaviorField({
value,
offered,
onCommit,
disabled,
locale,
}: {
value: string;
offered: ReadonlyArray<string>;
onCommit: (v: string) => void;
disabled?: boolean;
locale?: string;
}) {
return (
<div className="space-y-1 border-t pt-2.5" data-testid="field-delete-behavior">
<InspectorSelectField
label={t('designer.field.deleteBehavior', locale)}
value={value}
options={[
{ value: '', label: t('engine.form.none', locale) },
...offered.map((v) => ({ value: v, label: t(`designer.field.deleteBehavior.${v}`, locale) })),
]}
onCommit={onCommit}
disabled={disabled}
/>
<p className="text-[10px] leading-snug text-muted-foreground">
{t('designer.field.deleteBehavior.help', locale)}
</p>
</div>
);
}

/* ─────────────── Related list on the parent (relatedList*) ─────────────── */

/** The three states of `relatedList`, as the control names them. */
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,11 @@ const STUDIO_EN: Record<string, string> = {
'designer.field.lookup.allowCreate': 'Allow quick-create',
'designer.field.lookup.referenceNameVisible': 'Show Name When Unreadable',
'designer.field.lookup.referenceNameVisible.help': "Lets a reader of this record see the referenced record's name, as plain text with no link, even when they may not open that record. Nothing else of it is shown, and a field-level rule on the name still holds (the name then reads Record # and the id). Unset: such a reference shows as a restricted record.",
'designer.field.deleteBehavior': 'Delete Behavior',
'designer.field.deleteBehavior.help': 'What happens when the referenced record is deleted',
'designer.field.deleteBehavior.set_null': 'Set Null (clear the reference)',
'designer.field.deleteBehavior.cascade': 'Cascade (delete children)',
'designer.field.deleteBehavior.restrict': 'Restrict (block the delete)',
'designer.field.relatedList.heading': 'Related list on the parent record',
'designer.field.relatedList.shown': 'Show',
'designer.field.relatedList.hidden': 'Don\'t show',
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,11 @@ const STUDIO_ZH: Record<string, string> = {
'designer.field.lookup.allowCreate': '允许快速创建',
'designer.field.lookup.referenceNameVisible': '不可读时显示名称',
'designer.field.lookup.referenceNameVisible.help': '允许此记录的读者看到被引用记录的名称(纯文本,不带链接),即使其无权打开该记录。该记录的其他内容一律不显示;名称字段上的字段级规则仍然生效(此时名称显示为 Record # 加 id)。未设置:此类引用显示为「受限记录」。',
'designer.field.deleteBehavior': '删除行为',
'designer.field.deleteBehavior.help': '被引用记录删除时的处理方式',
'designer.field.deleteBehavior.set_null': '置空(清除引用)',
'designer.field.deleteBehavior.cascade': '级联(删除子记录)',
'designer.field.deleteBehavior.restrict': '限制(阻止删除)',
'designer.field.relatedList.heading': '父记录上的关联列表',
'designer.field.relatedList.shown': '显示',
'designer.field.relatedList.hidden': '不显示',
Expand Down
Loading