diff --git a/.changeset/29807-field-panel-delete-behavior.md b/.changeset/29807-field-panel-delete-behavior.md new file mode 100644 index 00000000000..93c8b9f762d --- /dev/null +++ b/.changeset/29807-field-panel-delete-behavior.md @@ -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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx index d4a58977ef9..32e8c985709 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx @@ -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 @@ -32,6 +33,7 @@ vi.mock('../previews/useObjectFields', () => ({ import { ObjectFieldInspector } from './ObjectFieldInspector'; import { __setCelFormulaLoader } from '../celAuthoring'; +import { t } from '../i18n'; afterEach(() => { cleanup(); @@ -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; + 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; + 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>({ + name: 'ticket', + fields: { title: { type: 'text', label: 'Title' }, ref: def }, + }); + return ( + ) => { + onPatch(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + onClearSelection={vi.fn()} + onSelectionChange={vi.fn()} + readOnly={false} + locale={'en-US'} + /> + ); + } + render(); + /** The `ref` definition the editor's Save would persist after the last edit. */ + const saved = () => (onPatch.mock.calls.at(-1)![0].fields as Record).ref; + return { onPatch, saved }; + } + + const control = () => screen.queryByRole('combobox', { name: LABEL }); + + async function optionNames(): Promise { + 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'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 23c6855e30c..0a8524c244a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -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 { + 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 @@ -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; } @@ -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 }); @@ -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 && ( + + (v ? patchDef({ deleteBehavior: v }) : unsetDefKey('deleteBehavior'))} + disabled={locked} + locale={locale} + /> + + )} {type === 'formula' && ( ; + onCommit: (v: string) => void; + disabled?: boolean; + locale?: string; +}) { + return ( +
+ ({ value: v, label: t(`designer.field.deleteBehavior.${v}`, locale) })), + ]} + onCommit={onCommit} + disabled={disabled} + /> +

+ {t('designer.field.deleteBehavior.help', locale)} +

+
+ ); +} + /* ─────────────── Related list on the parent (relatedList*) ─────────────── */ /** The three states of `relatedList`, as the control names them. */ diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index ecac994395d..b7ff20a3f6c 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -418,6 +418,11 @@ const STUDIO_EN: Record = { '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', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index a818310aec2..2c2125105ff 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -346,6 +346,11 @@ const STUDIO_ZH: Record = { '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': '不显示',