Skip to content

Commit 1c66988

Browse files
fix(studio): show, keep and author a controlled-by-parent object's optional master (#31202)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1qmTvsS4RmPr4CF2WqFHpZ)_ Fixes #30974 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 现在认得「可选主字段」:OWD 总览把 `required: false` 的唯一主从字段显示为主对象;Studio 保存不再把作者写的 `required: false` 改成 `true`;字段检查器里唯一主从字段的「必填」可以取消(写成 `required: false`),下方说明无主记录只有所有者和「查看所有记录」可见。 - 为什么改:#30905 裁决 A 允许受父级控制对象的唯一主从字段可选,Studio 仍按「必须必填」处理,保存会悄悄改写作者选定的访问模型(p2)。 - 风险与回滚:只动 `ui/packages/app-shell` 的 Studio 代码和一条 i18n 文案,不改 spec、不改运行时;回滚即还原本 PR。省略 `required` 的主字段仍按必填处理,两个以上主从字段时锁定照旧。 - 先看:`owd-sharing.ts`(主字段改由 spec 的 `controlledByParentMasterOf` 判定,保存只补省略的 `required`)、`ObjectFieldInspector.tsx`(`cbpOptionalMasterAllowed` 一段)、`owd-sharing.test.ts`(被 #30940 改标的那一行翻回一致)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/owd-sharing.test.ts`,预期全绿,其中 "one master without required" 与 "one optional master (required: false)" 两行都显示主对象 `crm_account`。 - 需要你做的:无,席位落地。 ## What changes ADR-0055 §1 as amended by #30905 ruling A lets a `controlled_by_parent` object's ONLY `master_detail` be declared `required: false`, an optional master whose records may name none. Studio still treated every master as required. This PR covers the card's three items: - **Display** (`owd-sharing.ts`, `deriveMasterObject`). Which field is the master is now the spec's own answer, `controlledByParentMasterOf` from `@objectstack/spec/data`, so an optional master is shown as the master and the two-master shapes stay as the spec rules them (Studio does not re-decide them). An omitted `required` on the object's only `master_detail` is read as required first, as the spec's stored-row conversion `cbp-master-detail-required-defaulted` reads it, which is ruling A's "Studio still defaults it to required". - **Save** (`owd-sharing.ts`, `requireCbpMasterDetails`, used by `PackageOwdOverviewPanel.tsx` and `ObjectSettingsPanel.tsx`). Switching to Controlled by parent still sets `required: true` on each `master_detail` that writes no `required` (the builder's force), and now keeps an explicit `required: false`. The overview's 409 prerequisite list (`cbpPrerequisite`) reads the same predicate, `cbpMasterDetailDefaultsRequired`. - **Authoring** (`ObjectFieldInspector.tsx`). On a cbp object's only `master_detail` the Required box is no longer locked on: clearing it writes `required: false` (never a dropped key, which would be defaulted back to required), and the help text under it, new key `designer.field.requiredCbpOptionalMaster` (en, zh), says a record that names no master is visible only to its owner and to View All Records on the object. Beside another `master_detail` the old lock and text stand, because the spec admits no optional master there (builder and `relationship/master-detail-required` refuse it). ## Why Triage graded it p2: a Studio save silently turned an author's optional master into a required one, which rewrites the access model they chose. ## Tests - The two display-agreement rows PR #30940 re-labelled flip back to agreement: "one master without required" in `owd-sharing.test.ts` and in `PackageOwdOverviewPanel.cbpRequiredMaster-12326.test.tsx` now name `crm_account`, and the spec parse accepts the shape. The per-row "parses" override column is gone: every row's parse verdict now equals "a master is named". - New rows: one optional master (`required: false`), a `required: false` master-detail beside the required one, two `master_detail` fields none required. - New: `requireCbpMasterDetails` keeps an explicit `required: false` (map, array, and mixed with an omitted one); the overview save keeps it, through the rendered dial and Save button. - Inspector, driven through the rendered checkbox: the only master-detail can be cleared to `required: false` and ticked back; beside another master-detail the box stays locked with the old reason. Run on head 3c18a37 (the code head; f7f9807 after it changes only the changeset): the three files, 3/3 files, 38/38 tests passed. <details><summary>Ablations (each mutation through scripts/ablation-replace.mjs; every restore proven blob == HEAD and `git diff HEAD` empty)</summary> | Mutation | Red | |:--|:--| | `deriveMasterObject` without the omitted-required default | 2 (both "one master without required" rows) | | master judged by the old `required === true` count instead of `controlledByParentMasterOf` | 3 (both optional-master rows, the overview save test) | | save predicate back to `required !== true` | 2 (both keeps-optional tests) | | inspector commit back to `v \|\| undefined` | 2 | | inspector lock back to `cbpMasterDetail && required === true` | 2 | | inspector help text back to the old key | 1 | </details> ## Acceptance notes - The overview names the master but does not say whether it is optional; the inspector's help text is where the optional master is explained. No new overview string was added. - A stored object whose only master omits `required` shows an unticked Required box (the box shows what is written), while the overview names it as the master. Ticking writes `required: true`; switching the model to Controlled by parent writes it too. - `ObjectSettingsPanel.tsx` gets a comment-only edit: its comment said the save forces every `master_detail` required, which this change makes false. - No `content/docs/frontend/` page describes the inspector's Required lock; the cbp docs belong to #30907. ## Verification All local readings on head f7f9807 unless noted; CI runs the rest of the farm. - Derived gate list (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 36 families, every one exit 0. `--ran` reconciliation: "36 derived famil(ies) accounted for — 36 run, 0 NOT-MEASURED (a DERIVED zero — all 36 recorded an exit code and none of them is 3)". The loop ran on 3c18a37; the four changeset families were re-run on f7f9807 (exit 0), and `pnpm check:type-check-debt`, which first answered exit 3 (it queued behind my own lock hold, nothing measured), was re-run under the lock on f7f9807: exit 0. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. `pnpm --filter @object-ui/i18n run type-check`: exit 0. - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ packages/app-shell/src/views/metadata-admin/inspectors/ packages/i18n/`: 659/659 files, 7206 passed, 14 skipped. The whole app-shell suite (2,210 files) is left to CI's sharded Test UI. - `pnpm --dir ui exec eslint` on the nine changed ui files: exit 0, 0 errors; the 17 warnings are all on lines this PR does not touch. - `pnpm --dir ui run check:new-line-citations`: "0 new citation(s)", exit 0. - Builds first: the `@objectstack/spec`, `formula`, `client`, `lint`, `sdui-parser` closures, then `@object-ui/app-shell^...`, both lock VERDICT command-exit 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (The changed ui files were linted locally, exit 0; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mJLhZasj7G83LSvsv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f4cb21 commit 1c66988

10 files changed

Lines changed: 239 additions & 102 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(studio): Studio shows, keeps and authors the optional master of a controlled-by-parent object (#30974)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
Since ADR-0055 §1 as amended (#30905, ruling A), a `controlled_by_parent` object's ONLY `master_detail` may be declared `required: false`: an optional master, whose records may name no master and are then visible only to their owner and to View All Records on the object. Studio still treated every master as required. It now follows the spec:
12+
13+
- **Display.** The package OWD overview names the master the spec's `controlledByParentMasterOf` names, so an optional master is shown as the master instead of "no master". An omitted `required` on the object's only `master_detail` is shown as the master too, the way the builder and stored data read it.
14+
- **Save.** Switching an object to Controlled by parent (in the OWD overview or the object's Settings tab) still sets `required: true` on each `master_detail` that writes no `required`, and now keeps an explicit `required: false`. A Studio save no longer turns an optional master required.
15+
- **Authoring.** On the object's only `master_detail`, the field inspector's Required box is no longer locked on: clearing it writes `required: false`, and the help text under it explains the optional master and who can see a record that names none. Beside another `master_detail` the lock stands, since the spec admits no optional master there.
16+
- **New string.** `@object-ui/i18n`'s studio namespace gains `designer.field.requiredCbpOptionalMaster` (en, zh).

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.cbpRequiredMaster-12326.test.tsx‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
* Since `@objectstack/spec` 18 (objectstack#23100, ADR-0055 §1) such an object
99
* derives its master from exactly one `master_detail` declared
1010
* `required: true`. Since ADR-0055 §1 as amended (objectstack#30905 ruling A)
11-
* the spec also reads an object's only `master_detail` that is not required as
12-
* an OPTIONAL master, whose rows may name none; the designer keeps writing
13-
* `required: true`, so the master it shows is one every row names. The
14-
* installed spec's `ObjectSchema` is the oracle for the object each edit
15-
* leaves behind.
11+
* an object's ONLY `master_detail` may be declared `required: false`, an
12+
* OPTIONAL master whose rows may name none. On that field the box stays free,
13+
* clearing it writes `required: false`, and the help text explains the
14+
* null-master owner rule (objectstack#30974). Beside another `master_detail`
15+
* the lock stands. The installed spec's `ObjectSchema` is the oracle for the
16+
* object each edit leaves behind.
1617
*/
1718

1819
import '@testing-library/jest-dom/vitest';
@@ -89,23 +90,51 @@ function mount(object: Row, selected: string) {
8990
const requiredBox = () => screen.getByRole('checkbox', { name: t('designer.field.required', 'en-US') });
9091

9192
const REASON = t('designer.field.requiredCbpMaster', 'en-US');
93+
const OPTIONAL_HELP = t('designer.field.requiredCbpOptionalMaster', 'en-US');
9294

9395
describe('ObjectFieldInspector — a controlled_by_parent master is required, locked (objectui#12326)', () => {
94-
it('the Required box of the master is on and locked, with the reason', () => {
95-
mount(
96+
it('beside another master-detail, the Required box of the master is on and locked, with the reason', () => {
97+
const { object } = mount(
9698
{
9799
name: 'crm_case',
98100
sharingModel: 'controlled_by_parent',
99-
fields: { account: { type: 'master_detail', reference: 'crm_account', required: true } },
101+
fields: {
102+
account: { type: 'master_detail', reference: 'crm_account', required: true },
103+
contact: { type: 'master_detail', reference: 'crm_contact', required: false },
104+
},
100105
},
101106
'account',
102107
);
108+
expect(ObjectSchema.safeParse(object()).success).toBe(true);
103109
expect(requiredBox()).toBeChecked();
104110
expect(requiredBox()).toBeDisabled();
105111
expect(screen.getByTestId('field-required-cbp-master')).toHaveTextContent(REASON);
106112
});
107113

108-
it('a stored master without required can be ticked, and then no longer cleared', () => {
114+
it('the only master-detail can be cleared to an optional master, written required: false (objectstack#30974)', () => {
115+
const { object, field } = mount(
116+
{
117+
name: 'crm_case',
118+
sharingModel: 'controlled_by_parent',
119+
fields: { account: { type: 'master_detail', reference: 'crm_account', required: true } },
120+
},
121+
'account',
122+
);
123+
expect(requiredBox()).toBeChecked();
124+
expect(requiredBox()).toBeEnabled();
125+
expect(screen.getByTestId('field-required-cbp-master')).toHaveTextContent(OPTIONAL_HELP);
126+
fireEvent.click(requiredBox());
127+
expect(field().required).toBe(false);
128+
expect(requiredBox()).not.toBeChecked();
129+
const parsed = ObjectSchema.safeParse(object());
130+
expect(parsed.success).toBe(true);
131+
expect((parsed.data?.fields as Record<string, { required?: boolean }>).account.required).toBe(false);
132+
// And back: ticking it makes the master required again.
133+
fireEvent.click(requiredBox());
134+
expect(field().required).toBe(true);
135+
});
136+
137+
it('a stored master without required can be ticked, and cleared again to an optional master', () => {
109138
const { object, field } = mount(
110139
{
111140
name: 'crm_case',
@@ -123,10 +152,12 @@ describe('ObjectFieldInspector — a controlled_by_parent master is required, lo
123152
expect(requiredBox()).toBeEnabled();
124153
fireEvent.click(requiredBox());
125154
expect(field().required).toBe(true);
126-
expect(requiredBox()).toBeDisabled();
155+
expect(requiredBox()).toBeEnabled();
127156
const after = ObjectSchema.safeParse(object());
128157
expect(after.success).toBe(true);
129158
expect((after.data?.fields as Record<string, { required?: boolean }>).account.required).toBe(true);
159+
fireEvent.click(requiredBox());
160+
expect(field().required).toBe(false);
130161
});
131162

132163
it('outside controlled_by_parent the box stays free and no reason is shown', () => {
@@ -175,6 +206,6 @@ describe('ObjectFieldInspector — a controlled_by_parent master is required, lo
175206
await userEvent.click(within(list).getByRole('option', { name: 'Master-Detail' }));
176207
expect(field()).toEqual({ type: 'master_detail', label: 'Account', reference: 'crm_account', required: true });
177208
expect(ObjectSchema.safeParse(object()).success).toBe(true);
178-
expect(requiredBox()).toBeDisabled();
209+
expect(requiredBox()).toBeChecked();
179210
});
180211
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,12 +1087,23 @@ export function ObjectFieldInspector({
10871087

10881088
/**
10891089
* objectui#12326 — on a `controlled_by_parent` object a `master_detail` field
1090-
* is `required: true` and its Required box is locked on: the object derives
1091-
* its master from exactly one required `master_detail` (ADR-0055 §1), and the
1092-
* save door refuses any other value. `ObjectSettingsPanel` sets it when the
1093-
* sharing model is chosen; this inspector keeps it from being cleared.
1090+
* is `required: true` and its Required box is locked on once ticked: the
1091+
* object derives its master from exactly one required `master_detail`
1092+
* (ADR-0055 §1). `ObjectSettingsPanel` sets it when the sharing model is
1093+
* chosen; this inspector keeps it from being cleared.
1094+
*
1095+
* The one exception is the object's ONLY `master_detail` (ADR-0055 §1 as
1096+
* amended, objectstack#30905 ruling A): it may be an optional master,
1097+
* declared `required: false`, whose rows may name no master and are then
1098+
* readable only by their owner and by View All on the object. There the box
1099+
* stays free, clearing it writes `required: false` (an omitted `required`
1100+
* would be defaulted back to required), and the help text says what an
1101+
* optional master means. Beside another `master_detail` the spec admits no
1102+
* optional master, so the lock stands.
10941103
*/
10951104
const cbpMasterDetail = draft.sharingModel === 'controlled_by_parent' && type === 'master_detail';
1105+
const cbpOptionalMasterAllowed =
1106+
cbpMasterDetail && view.entries.filter((e) => e.def.type === 'master_detail').length === 1;
10961107

10971108
/**
10981109
* A retype keeps a picklist binding only on a type the spec lets name one.
@@ -1470,10 +1481,13 @@ export function ObjectFieldInspector({
14701481
<InspectorCheckboxField
14711482
label={tr('designer.field.required')}
14721483
value={!!def.required}
1473-
onCommit={(v) => patchDef({ required: v || undefined })}
1484+
// An optional master is written as `required: false`, never by
1485+
// dropping the key (objectstack#30905 ruling A).
1486+
onCommit={(v) => patchDef({ required: cbpOptionalMasterAllowed ? v : v || undefined })}
14741487
// Locked once it is on: the box can still be ticked on a stored
1475-
// master that lacks it, never cleared (objectui#12326).
1476-
disabled={locked || (cbpMasterDetail && def.required === true)}
1488+
// master that lacks it, never cleared (objectui#12326), except
1489+
// on the object's only `master_detail`, which may be optional.
1490+
disabled={locked || (cbpMasterDetail && !cbpOptionalMasterAllowed && def.required === true)}
14771491
/>
14781492
<InspectorCheckboxField
14791493
label={tr('designer.field.unique')}
@@ -1485,7 +1499,7 @@ export function ObjectFieldInspector({
14851499
</DisplayPatchLock>
14861500
{cbpMasterDetail && (
14871501
<p data-testid="field-required-cbp-master" className="text-[11px] text-muted-foreground">
1488-
{tr('designer.field.requiredCbpMaster')}
1502+
{tr(cbpOptionalMasterAllowed ? 'designer.field.requiredCbpOptionalMaster' : 'designer.field.requiredCbpMaster')}
14891503
</p>
14901504
)}
14911505
<TextareaField

‎ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,11 @@ export function ObjectSettingsPanel({
207207
{ value: 'controlled_by_parent', label: t('engine.studio.settings.sharingControlledByParent', locale) },
208208
];
209209
// objectui#12326 — choosing `controlled_by_parent` sets `required: true` on
210-
// every `master_detail` field in the same patch: the spec admits no other
211-
// value there (ADR-0055 §1), and the save door refuses an omitted one. The
212-
// fields are written only when that changes them.
210+
// each `master_detail` field that writes no `required`, in the same patch:
211+
// the save door would read the omission as an optional master. An explicit
212+
// `required: false` is an optional master the author chose (ADR-0055 §1 as
213+
// amended, objectstack#30905 ruling A) and is kept. The fields are written
214+
// only when that changes them.
213215
const pickSharingModel = (v: string) => {
214216
if (!v) {
215217
onPatch({ sharingModel: undefined });

‎ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.cbpRequiredMaster-12326.test.tsx‎

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,13 @@
66
*
77
* Since `@objectstack/spec` 18 (objectstack#23100, ADR-0055 §1) the master is
88
* the ONE `master_detail` declared `required: true`; zero or two or more derive
9-
* none. The row shows that master, or says there is none. Switching a row to
10-
* `controlled_by_parent` sets `required: true` on its `master_detail` fields in
11-
* the saved draft, as the per-object Settings tab does. The installed spec's
9+
* none. ADR-0055 §1 as amended (objectstack#30905 ruling A) adds an OPTIONAL
10+
* master: an object's only `master_detail` declared `required: false`. The row
11+
* shows the master, required or optional, or says there is none. Switching a
12+
* row to `controlled_by_parent` sets `required: true` on each `master_detail`
13+
* that writes no `required`, as the per-object Settings tab does, and keeps an
14+
* optional master as written (objectstack#30974). The installed spec's
1215
* `ObjectSchema` is the oracle for the saved body.
13-
*
14-
* ADR-0055 §1 as amended (objectstack#30905 ruling A) adds an OPTIONAL master:
15-
* an object's only `master_detail` declared `required: false`. The parse cannot
16-
* tell an omitted `required` from `false`, so it admits the omitted shape too.
17-
* This overview still names none for an omitted `required`, as the runtime
18-
* does for an object that reaches it unparsed (it reads `required: false`
19-
* literally, never its absence), and that row records the parse's answer
20-
* apart from the master shown.
2116
*/
2217

2318
import '@testing-library/jest-dom/vitest';
@@ -62,15 +57,14 @@ afterEach(cleanup);
6257
const cbp = (fields: Body): Body => ({ name: 'crm_case', label: 'Case', sharingModel: 'controlled_by_parent', fields });
6358

6459
describe('PackageOwdOverviewPanel — the master shown is the one the runtime derives (objectui#12326)', () => {
65-
// [label, fields, the master shown, whether the spec parse accepts it when
66-
// that differs from "a master is shown"]
67-
const SHAPES: Array<[string, Body, string | undefined, boolean?]> = [
60+
// [label, fields, the master shown]
61+
const SHAPES: Array<[string, Body, string | undefined]> = [
6862
['one required master', { account: { type: 'master_detail', reference: 'crm_account', required: true } }, 'crm_account'],
63+
['one master without required', { account: { type: 'master_detail', reference: 'crm_account' } }, 'crm_account'],
6964
[
70-
'one master without required (the parse reads it as an optional master; no master is shown)',
71-
{ account: { type: 'master_detail', reference: 'crm_account' } },
72-
undefined,
73-
true,
65+
'one optional master (required: false)',
66+
{ account: { type: 'master_detail', reference: 'crm_account', required: false } },
67+
'crm_account',
7468
],
7569
[
7670
'two required masters',
@@ -82,10 +76,9 @@ describe('PackageOwdOverviewPanel — the master shown is the one the runtime de
8276
],
8377
];
8478

85-
it.each(SHAPES)('%s', async (_label, fields, master, parses) => {
86-
// The oracle: the spec accepts the shapes a master is shown for, and the
87-
// one row that says otherwise.
88-
expect(ObjectSchema.safeParse(cbp(fields)).success).toBe(parses ?? master !== undefined);
79+
it.each(SHAPES)('%s', async (_label, fields, master) => {
80+
// The oracle: the spec accepts exactly the shapes a master is shown for.
81+
expect(ObjectSchema.safeParse(cbp(fields)).success).toBe(master !== undefined);
8982
renderPanel({ crm_case: cbp(fields) });
9083
const cell = await screen.findByTestId('owd-master-crm_case');
9184
expect(cell).toHaveTextContent(
@@ -127,6 +120,30 @@ describe('PackageOwdOverviewPanel — switching a row to controlled_by_parent (o
127120
expect((parsed.data?.fields as Record<string, { required?: boolean }>).account.required).toBe(true);
128121
});
129122

123+
it('keeps an optional master as written: the save never turns required: false required (objectstack#30974)', async () => {
124+
const body: Body = {
125+
name: 'crm_case',
126+
label: 'Case',
127+
sharingModel: 'private',
128+
fields: { account: { type: 'master_detail', reference: 'crm_account', required: false } },
129+
};
130+
const { saved } = renderPanel({ crm_case: body });
131+
await screen.findByTestId('owd-row-crm_case');
132+
fireEvent.keyDown(screen.getByTestId('owd-internal-crm_case'), { key: 'ArrowDown' });
133+
fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: CBP_LABEL }));
134+
expect(screen.getByTestId('owd-master-crm_case')).toHaveTextContent(
135+
tFormat('engine.studio.owd.byParent', 'en-US', { master: 'crm_account' }),
136+
);
137+
138+
fireEvent.click(screen.getByTestId('owd-save'));
139+
await waitFor(() => expect(saved.length).toBe(1));
140+
expect(saved[0].body.sharingModel).toBe('controlled_by_parent');
141+
expect((saved[0].body.fields as Record<string, Body>).account.required).toBe(false);
142+
const parsed = ObjectSchema.safeParse(saved[0].body);
143+
expect(parsed.success).toBe(true);
144+
expect((parsed.data?.fields as Record<string, { required?: boolean }>).account.required).toBe(false);
145+
});
146+
130147
it('a row with two master-detail fields shows no master before the save', async () => {
131148
renderPanel({
132149
crm_case: {

‎ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
import { errorCodeIs } from '@object-ui/types';
5555
import { readFields } from '../metadata-admin/previews/object-fields-io.js';
5656
import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n.js';
57-
import { deriveMasterObject, requireCbpMasterDetails } from './owd-sharing.js';
57+
import { cbpMasterDetailDefaultsRequired, deriveMasterObject, requireCbpMasterDetails } from './owd-sharing.js';
5858
import { toast } from 'sonner';
5959

6060

@@ -69,13 +69,14 @@ interface OwdRow {
6969
internal: string;
7070
/**
7171
* The master (parent) object the row derives under `controlled_by_parent`:
72-
* the reference of its one required `master_detail` (objectui#12326). For a
73-
* row loaded as `controlled_by_parent` it is read from the body as stored,
74-
* which is what the runtime derives. For any other row it is the master the
75-
* row would derive once switched and saved here, since that save sets
76-
* `required: true` on its `master_detail` fields. Undefined = no master: zero
77-
* `master_detail` fields, or two or more, an authoring error the save door
78-
* refuses.
72+
* the reference of its one master `master_detail`, required or optional
73+
* (objectui#12326, objectstack#30905 ruling A). For a row loaded as
74+
* `controlled_by_parent` it is read from the body as stored, which is what
75+
* the runtime derives. For any other row it is the master the row would
76+
* derive once switched and saved here, since that save sets `required: true`
77+
* on each `master_detail` that writes no `required`. Undefined = no master:
78+
* zero `master_detail` fields, or two or more with no one master, an
79+
* authoring error the save door refuses.
7980
*/
8081
master?: string;
8182
/** True when this object already has an unpublished draft. */
@@ -138,7 +139,7 @@ function owdLabel(value: string, locale: SupportedLocale): string {
138139
*/
139140
function cbpPrerequisite(fields: unknown): string[] {
140141
return readFields(fields)
141-
.entries.filter((e) => e.def.type === 'master_detail' && e.def.required !== true)
142+
.entries.filter((e) => cbpMasterDetailDefaultsRequired(e.def))
142143
.map((e) => `${e.name} → ${typeof e.def.reference === 'string' ? e.def.reference : '?'}`);
143144
}
144145

@@ -399,8 +400,10 @@ export function PackageOwdOverviewPanel({
399400
if (s.edit.internal) next.sharingModel = s.edit.internal;
400401
else delete next.sharingModel;
401402
// objectui#12326 — switching to `controlled_by_parent` sets
402-
// `required: true` on every `master_detail` field, as the per-object
403-
// Settings tab does: the save door refuses an omitted one there.
403+
// `required: true` on each `master_detail` field that writes no
404+
// `required`, as the per-object Settings tab does: the save door would
405+
// read the omission as an optional master. An explicit
406+
// `required: false` is an optional master and is kept.
404407
let relations: string[] = [];
405408
if (s.edit.internal === 'controlled_by_parent' && next.fields !== undefined) {
406409
relations = cbpPrerequisite(next.fields);

0 commit comments

Comments
 (0)