Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit d21bd08

Browse files
refactor(app-shell): Studio's object settings panel picks with the shared Select (objectui#11865) (#11995)
Part of #11865 Clause-②: no The object settings slice of objectui#11865 (claim 6064876007): the five browser-native select elements on Studio's object Settings tab become the shared Radix `Select`. The card stays open for the other native selects. The earlier slices are objectui#11934 (the Group panel), objectui#11976 (the validation editor) and objectui#11990 (*Explain access*). ## What changed - `packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx`: one module-private `SettingsPicker`, drawn with the shared `Select` from `@object-ui/components`, renders all five pickers: the internal sharing model, the external sharing model, the name field, the lifecycle field, and the highlight fields' "+ Add field…". It follows the validation editor's `RulePicker`. Items carry their option's index, so the options whose value is `''` ("not set", "auto-derived", "auto-detect", "+ Add field…") need no empty `SelectItem`. A stored value that none of the options carries gets an item of its own. Each caller turns the picked value into the same patch as before: the mapping expressions are the native `onChange` bodies, with the event's value replaced by the picked value. - Each labelled picker stays inside its wrapping label element, which names the trigger as it named the native control. The "+ Add field…" picker had no label and still has none. The two OWD triggers keep their test ids (`owd-internal-select`, `owd-external-select`); the other three gain `name-field-select`, `stage-field-select` and `highlight-add-select`. - Untouched: the Basics inspector, the capability checkboxes, the highlight chips and their remove buttons, the descriptions under the OWD dials, the props and the exports. No i18n key is added: the panel's set of `engine.*` keys is identical to the base's (diffed). - `ObjectSettingsPanel.sharedSelect-11865.test.tsx` (new), 35 tests. - `ObjectSettingsPanel.test.tsx`: the five OWD-dial cases that drove the native control (option list, pick, clear, external default, external clear) now open and pick through the trigger, and check the same things. - `StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx`: its Data pillar case read the dial's `value`. The trigger is a button, whose `value` is `''` whatever the dial holds, so that assertion would have passed vacuously. It now reads the trigger's text: the unset label is shown and the published `private` label is not. - `.changeset/11865-object-settings-shared-select.md`: `@object-ui/app-shell: patch`. ## Hypotheses - **H1, value parity: held.** A scratch probe (uncommitted, deleted) fired a `change` event on every option of each native select on base `0bbb67b9a` and recorded the patch. The new suite's 24-row table pins every option of every picker against those literals, compared as JSON text, with the keys that hold `undefined` named: - each "not set", "auto-derived" and "auto-detect" option writes its key holding `undefined`; - "None" writes `stageField: false`; - a field writes its name, and an added highlight is appended to the list; - "+ Add field…" writes nothing. A table-coverage test pins each picker's listed options in order. Caveat: the probe's `change` event fired for the current option too, so the re-pick rows (no write) pin the primitive, not a browser reading of the native control. - **H2, labels, keyboard and read-only: held.** The probe read each native select's accessible name on base. On head each trigger computes the same name: the four label texts, and `''` for the "+ Add field…" picker on both. Enter opens a picker, and Enter on an option selects it. The panel has a read-only state: `disabled`, which `StudioDesignSurface` passes from its read-only flag. In it, the four triggers are disabled, wear the primitive's own `disabled:` classes, and do not open. The "+ Add field…" picker is not drawn, as on base. - **H3, a stored value outside the options: held.** Each of these is shown in the trigger: a name field naming a missing field, a lifecycle field naming a field that is not a select field, and sharing models neither dial offers (`full`, `read_write`). The native control showed its first option there. The missing name field is listed first, and re-picking it writes nothing. - **H4, first-load bytes: byte-neutral locally.** On `e02c4c116`: `apps/console` `vite build` plus `node scripts/check-eager-closure-budget.mjs`, on head and on the same tree with the panel at its base blob (landing and restore proven by hash). - Both legs print `Console eager closure is 3311.2 KB gzipped across 337 of 2459 chunks (budget: 3312.0 KB, headroom: 0.8 KB).` - `eagerGzipBytes` is 3390714 on both legs, and `eagerRawBytes` is equal. The only eager entry that differs is the entry chunk's file-name hash; its bytes are identical. - The panel ships in the lazy `studioBuilder` chunk on both legs. The budget bot's reading on this PR is the authority. ## Reverse leg With the implementation committed, the panel was written from its base blob (its hash equals the base blob: 5 native select elements, 0 `SettingsPicker`). Then the new suite, `ObjectSettingsPanel.test.tsx` and the served-draft suite ran: `Tests 41 failed | 11 passed (52)`. - Red: all 35 new pins, the five rewritten OWD-dial cases, and the served-draft Data pillar case. - Green: the eight untouched cases of `ObjectSettingsPanel.test.tsx` and the served-draft suite's three other cases. That is the direction the suite header predicts. The restore, `git checkout HEAD --` under a trap, is proven: the hash equals the HEAD blob, and `git diff HEAD` is 0 bytes. ## Gates (on `e02c4c116`) - The seven suites that mount the real panel, repo-root `pnpm exec vitest run`: `Test Files 7 passed (7)`, `Tests 61 passed (61)`. `DataPillar.hiddenSystemFields-11780.test.tsx` stubs the panel and is not among them. - `column-identity.ratchet` and `one-authority-per-exported-name-6273`: `Test Files 2 passed (2)`, `Tests 18 passed (18)`. - `pnpm --filter @object-ui/app-shell type-check`, after the app-shell dependency closure build: exit 0. By `--listFilesOnly`, the panel is in the main program, and the panel and the three touched suites are in the test program. - `pnpm exec eslint` on the four touched `.tsx` files: exit 0, 0 errors, 0 warnings. - `check-changeset-presence`, `check-changeset-no-major`, `check:new-line-citations` (0 new) and `check:control-bytes`: exit 0. - Also exit 0: the changeset fixed, overwrite and claims gates, `pending-changeset-literals`, `test-path-roots`, the three vi-mock gates, `phantom-deps`, `unreferenced-sources`, `i18n-keys`, `i18n-designer-parity`, `i18n-drift`, `metadata-write-doors`, `esm-specifiers`, `self-import`, lint coverage, type-check coverage, `handler-key-reads` and `designer-field-key-parity`. `component-surface-parity` and `i18n-dead-keys` also exit 0; both are report-only. - Declared to CI: the full `pnpm test` farm and repo-wide `pnpm lint`. ## Acceptance notes - The "+ Add field…" picker has no accessible name, on base and on head: it has no label, and this slice keeps each control's name as it was. Naming it, for example by the "Highlight fields" caption, is not in this slice. Noted, not filed. - The name-field and "+ Add field…" lists hold every field of the object, so they can be long. They use `Select`, as the claim directs and as the earlier slices measured. `Select` jumps to an option by its typed leading letters. - Browser: not measured in this slice. Keyboard and read-only behaviour are pinned in the test DOM only. --- _Generated by [Claude Code](https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b8388b commit d21bd08

5 files changed

Lines changed: 484 additions & 91 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio's object Settings tab picks the sharing model, the external sharing model, the name field, the lifecycle field and the next highlight field with the shared `Select`, the control Studio's validation rule editor already picks with (objectui#11865, the object settings part of that card).
6+
7+
The five pickers were browser-native selects, so they looked and behaved differently from Studio's other dropdowns. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour.
8+
9+
What they write is unchanged. Each option writes the same patch as before, key for key: "not set", "auto-derived" and "auto-detect" still clear their key, "None" on the lifecycle field still writes `stageField: false`, and "+ Add field…" still writes nothing. Re-picking the current option writes nothing. Each labelled picker keeps the accessible name its label gave the native select.
10+
11+
On a read-only package the four pickers are disabled and wear the shared control's own disabled look (objectui#11781), and the "add field" picker is not shown, as before.
12+
13+
One display change: an object whose stored value is not among a picker's options now shows that value. This happens, for example, when the name field names a field that has been removed, or the lifecycle field names one that is not a select field. The native select showed its first option instead ("not set", "auto-derived" or "auto-detect"), which is not what the object says.
14+
15+
**Clause-②: no.** No published face moves: `ObjectSettingsPanel` takes the same props, the package entry exports the same names, and no i18n key is added. What moves is the panel's own markup, described above.
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Studio's object settings panel picks with the shared `Select` (objectui#11865).
11+
*
12+
* The Settings tab picked the internal and external sharing model, the name
13+
* field, the lifecycle field and the next highlight field with browser-native
14+
* selects, beside the shared Radix `Select` the rest of Studio picks with. The
15+
* card asks for one control for one kind of choice, surface by surface; this
16+
* suite covers this panel's pickers.
17+
*
18+
* What is pinned:
19+
* - each picker IS the primitive (a Radix combobox trigger), shows the
20+
* object's value, and no native select is left;
21+
* - the four labelled pickers keep the name their wrapping label gave the
22+
* native control; the "add field" picker had no label and still has no
23+
* name;
24+
* - every option of every picker writes the `onPatch` payload the native
25+
* control wrote, compared as JSON text, with the keys that hold
26+
* `undefined` named (JSON text drops them). That covers each "not set",
27+
* "auto-derived" and "auto-detect" option, which writes its key holding
28+
* `undefined`, and "None", which writes `stageField: false`;
29+
* - re-picking the current option writes nothing, and neither does
30+
* "add field";
31+
* - read-only: each trigger is disabled, wears the primitive's own disabled
32+
* look, and does not open; the "add field" picker is not drawn, as before;
33+
* - a stored value no option carries is what the trigger shows;
34+
* - the keyboard alone opens a picker and selects.
35+
*
36+
* DIRECTION, observed against the native control: every pin here is red
37+
* there, because each one reads the pickers as the primitive's triggers. What
38+
* makes the write pins guards of "the conversion changed nothing the panel
39+
* writes" is the literal each compares against: a `change` event on the
40+
* pre-conversion panel's native control wrote that same JSON, with the same
41+
* `undefined` keys, read once on that component. The names were read there
42+
* the same way. That probe's `change` event fired for the current option too,
43+
* so the re-pick rows pin the primitive, not a browser reading of the native
44+
* control.
45+
*/
46+
47+
import '@testing-library/jest-dom/vitest';
48+
import * as React from 'react';
49+
import { afterEach, describe, expect, it, vi } from 'vitest';
50+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
51+
import { Select, SelectTrigger, SelectValue } from '@object-ui/components';
52+
import { t } from '../metadata-admin/i18n';
53+
import { ObjectSettingsPanel } from './ObjectSettingsPanel';
54+
55+
afterEach(cleanup);
56+
57+
const en = (key: string) => t(key, 'en-US');
58+
59+
const FIELDS = {
60+
name: { type: 'text', label: 'Name' },
61+
status: { type: 'select', label: 'Status' },
62+
stage: { type: 'select' },
63+
secret: { type: 'text', label: 'Secret', hidden: true },
64+
code: { label: 'Code' },
65+
};
66+
67+
/** An object with every picker set to one of its own options. */
68+
const DRAFT: Record<string, unknown> = {
69+
fields: FIELDS,
70+
sharingModel: 'public_read',
71+
externalSharingModel: 'private',
72+
nameField: 'name',
73+
stageField: 'status',
74+
highlightFields: ['name'],
75+
};
76+
77+
const PICKERS = [
78+
'owd-internal-select',
79+
'owd-external-select',
80+
'name-field-select',
81+
'stage-field-select',
82+
'highlight-add-select',
83+
] as const;
84+
85+
/** The labelled pickers, by the i18n key of the label that names each. */
86+
const NAMED_BY: ReadonlyArray<readonly [string, string]> = [
87+
['owd-internal-select', 'engine.studio.settings.sharingModel'],
88+
['owd-external-select', 'engine.studio.settings.sharingExternal'],
89+
['name-field-select', 'engine.studio.settings.nameField'],
90+
['stage-field-select', 'engine.studio.settings.stageField'],
91+
];
92+
93+
function renderPanel(draft: Record<string, unknown> = DRAFT, disabled?: boolean) {
94+
const onPatch = vi.fn();
95+
const utils = render(
96+
<ObjectSettingsPanel name="leave_request" draft={draft} onPatch={onPatch} disabled={disabled} locale="en-US" />,
97+
);
98+
return { ...utils, onPatch };
99+
}
100+
101+
/** Open a picker from the keyboard and return the options it lists, in order. */
102+
async function openPicker(testId: string): Promise<HTMLElement[]> {
103+
fireEvent.keyDown(screen.getByTestId(testId), { key: 'ArrowDown' });
104+
const listbox = await screen.findByRole('listbox');
105+
return within(listbox).getAllByRole('option');
106+
}
107+
108+
async function pick(testId: string, label: string): Promise<void> {
109+
const options = await openPicker(testId);
110+
const option = options.find((o) => o.textContent === label);
111+
if (!option) throw new Error(`${testId} lists no "${label}": ${options.map((o) => o.textContent).join(' | ')}`);
112+
fireEvent.click(option);
113+
}
114+
115+
/** The `disabled:` utilities the shared `SelectTrigger` wears. */
116+
function primitiveDisabledLook(): string[] {
117+
const { getByRole, unmount } = render(
118+
<Select disabled>
119+
<SelectTrigger>
120+
<SelectValue />
121+
</SelectTrigger>
122+
</Select>,
123+
);
124+
const tokens = getByRole('combobox').className.split(/\s+/).filter((c) => c.startsWith('disabled:'));
125+
unmount();
126+
return tokens;
127+
}
128+
129+
describe('the object settings pickers are the shared Select (objectui#11865)', () => {
130+
it('renders each picker as the Radix combobox trigger, showing the object’s value', () => {
131+
const { container } = renderPanel();
132+
expect(container.querySelector('select')).toBeNull();
133+
134+
const shown: Record<string, string> = {};
135+
for (const id of PICKERS) {
136+
const trigger = screen.getByTestId(id);
137+
expect(trigger.tagName).toBe('BUTTON');
138+
expect(trigger).toHaveAttribute('role', 'combobox');
139+
shown[id] = trigger.textContent ?? '';
140+
}
141+
expect(shown).toEqual({
142+
'owd-internal-select': en('engine.studio.settings.sharingPublicRead'),
143+
'owd-external-select': en('engine.studio.settings.sharingPrivate'),
144+
'name-field-select': 'Name (name)',
145+
'stage-field-select': 'Status (status)',
146+
'highlight-add-select': en('engine.studio.settings.addFieldOption'),
147+
});
148+
});
149+
150+
it('each labelled picker keeps the name its label gave the native control', () => {
151+
renderPanel();
152+
for (const [id, key] of NAMED_BY) {
153+
expect(screen.getByRole('combobox', { name: en(key) }), id).toBe(screen.getByTestId(id));
154+
}
155+
});
156+
157+
it('the "add field" picker had no label and still has no name', () => {
158+
renderPanel();
159+
expect(screen.getByRole('combobox', { name: '' })).toBe(screen.getByTestId('highlight-add-select'));
160+
});
161+
162+
it('"None" on the lifecycle field is what the trigger shows for stageField: false', () => {
163+
renderPanel({ ...DRAFT, stageField: false });
164+
expect(screen.getByTestId('stage-field-select')).toHaveTextContent(en('engine.studio.settings.stageNone'));
165+
});
166+
});
167+
168+
/**
169+
* [picker, option label, the JSON text `onPatch` received, the keys of the
170+
* patch that hold `undefined`]. `null`: nothing is written — the option is the
171+
* current one, or it is "add field".
172+
*/
173+
const WRITES: ReadonlyArray<readonly [string, string, string | null, readonly string[]]> = [
174+
['owd-internal-select', en('engine.studio.settings.sharingUnset'), '{}', ['sharingModel']],
175+
['owd-internal-select', en('engine.studio.settings.sharingPrivate'), '{"sharingModel":"private"}', []],
176+
['owd-internal-select', en('engine.studio.settings.sharingPublicRead'), null, []],
177+
['owd-internal-select', en('engine.studio.settings.sharingPublicReadWrite'), '{"sharingModel":"public_read_write"}', []],
178+
['owd-internal-select', en('engine.studio.settings.sharingControlledByParent'), '{"sharingModel":"controlled_by_parent"}', []],
179+
['owd-external-select', en('engine.studio.settings.sharingExternalUnset'), '{}', ['externalSharingModel']],
180+
['owd-external-select', en('engine.studio.settings.sharingPrivate'), null, []],
181+
['owd-external-select', en('engine.studio.settings.sharingPublicRead'), '{"externalSharingModel":"public_read"}', []],
182+
['owd-external-select', en('engine.studio.settings.sharingPublicReadWrite'), '{"externalSharingModel":"public_read_write"}', []],
183+
['owd-external-select', en('engine.studio.settings.sharingControlledByParent'), '{"externalSharingModel":"controlled_by_parent"}', []],
184+
['name-field-select', en('engine.studio.settings.autoDerive'), '{}', ['nameField']],
185+
['name-field-select', 'Name (name)', null, []],
186+
['name-field-select', 'Status (status)', '{"nameField":"status"}', []],
187+
['name-field-select', 'stage', '{"nameField":"stage"}', []],
188+
['name-field-select', 'Secret (secret)', '{"nameField":"secret"}', []],
189+
['name-field-select', 'Code (code)', '{"nameField":"code"}', []],
190+
['stage-field-select', en('engine.studio.settings.autoDetect'), '{}', ['stageField']],
191+
['stage-field-select', en('engine.studio.settings.stageNone'), '{"stageField":false}', []],
192+
['stage-field-select', 'Status (status)', null, []],
193+
['stage-field-select', 'stage', '{"stageField":"stage"}', []],
194+
['highlight-add-select', en('engine.studio.settings.addFieldOption'), null, []],
195+
['highlight-add-select', 'Status (status)', '{"highlightFields":["name","status"]}', []],
196+
['highlight-add-select', 'stage', '{"highlightFields":["name","stage"]}', []],
197+
['highlight-add-select', 'Code (code)', '{"highlightFields":["name","code"]}', []],
198+
];
199+
200+
describe('every option writes what the native control wrote', () => {
201+
it('the table covers every option of every picker, in the order each lists them', async () => {
202+
for (const id of PICKERS) {
203+
renderPanel();
204+
const listed = (await openPicker(id)).map((o) => o.textContent);
205+
expect(listed, id).toEqual(WRITES.filter(([p]) => p === id).map(([, label]) => label));
206+
cleanup();
207+
}
208+
});
209+
210+
it.each(WRITES.map((row) => [`${row[0]} → ${row[1]}`, ...row] as const))(
211+
'%s',
212+
async (_name, testId, label, json, undefinedKeys) => {
213+
const { onPatch } = renderPanel();
214+
await pick(testId, label);
215+
if (json === null) {
216+
expect(onPatch).not.toHaveBeenCalled();
217+
return;
218+
}
219+
expect(onPatch).toHaveBeenCalledTimes(1);
220+
const patch = onPatch.mock.calls[0][0] as Record<string, unknown>;
221+
expect(JSON.stringify(patch)).toBe(json);
222+
expect(Object.keys(patch).filter((k) => patch[k] === undefined)).toEqual(undefinedKeys);
223+
},
224+
);
225+
});
226+
227+
describe('read-only follows the primitive (objectui#11781)', () => {
228+
it('each picker is disabled, wears the primitive’s disabled look, and does not open', () => {
229+
const look = primitiveDisabledLook();
230+
expect(look.length, 'the primitive carries no disabled look to compare with').toBeGreaterThan(0);
231+
const { onPatch } = renderPanel(DRAFT, true);
232+
for (const [id] of NAMED_BY) {
233+
const trigger = screen.getByTestId(id);
234+
expect(trigger).toBeDisabled();
235+
for (const token of look) expect(trigger, `${id} lacks ${token}`).toHaveClass(token);
236+
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
237+
expect(screen.queryByRole('listbox'), `${id} opened while read-only`).toBeNull();
238+
}
239+
// As before the conversion, read-only draws no "add field" picker at all.
240+
expect(screen.queryByTestId('highlight-add-select')).toBeNull();
241+
expect(onPatch).not.toHaveBeenCalled();
242+
});
243+
244+
it('CONTROL — writable: the same pickers are drawn and enabled', () => {
245+
renderPanel();
246+
for (const id of PICKERS) expect(screen.getByTestId(id)).toBeEnabled();
247+
});
248+
});
249+
250+
describe('a stored value no option carries is what the trigger shows', () => {
251+
it('a name field the object does not have: shown and listed first, and re-picking it writes nothing', async () => {
252+
const { onPatch } = renderPanel({ ...DRAFT, nameField: 'ghost' });
253+
// The native control showed "(auto-derived)" here, as if no name field were set.
254+
expect(screen.getByTestId('name-field-select')).toHaveTextContent('ghost');
255+
const listed = (await openPicker('name-field-select')).map((o) => o.textContent);
256+
expect(listed).toEqual([
257+
'ghost',
258+
en('engine.studio.settings.autoDerive'),
259+
'Name (name)',
260+
'Status (status)',
261+
'stage',
262+
'Secret (secret)',
263+
'Code (code)',
264+
]);
265+
fireEvent.click(screen.getAllByRole('option')[0]);
266+
expect(onPatch).not.toHaveBeenCalled();
267+
});
268+
269+
it('a lifecycle field that is not a select field is shown, not "auto-detect"', () => {
270+
renderPanel({ ...DRAFT, stageField: 'name' });
271+
expect(screen.getByTestId('stage-field-select')).toHaveTextContent('name');
272+
});
273+
274+
it('a sharing model neither dial offers is shown, not "not set"', () => {
275+
renderPanel({ ...DRAFT, sharingModel: 'full', externalSharingModel: 'read_write' });
276+
expect(screen.getByTestId('owd-internal-select')).toHaveTextContent('full');
277+
expect(screen.getByTestId('owd-external-select')).toHaveTextContent('read_write');
278+
});
279+
});
280+
281+
describe('the keyboard alone picks', () => {
282+
it('Enter opens a picker and Enter on an option selects it', async () => {
283+
const { onPatch } = renderPanel();
284+
fireEvent.keyDown(screen.getByTestId('stage-field-select'), { key: 'Enter' });
285+
const listbox = await screen.findByRole('listbox');
286+
fireEvent.keyDown(within(listbox).getByRole('option', { name: 'stage' }), { key: 'Enter' });
287+
expect(onPatch).toHaveBeenCalledTimes(1);
288+
expect(JSON.stringify(onPatch.mock.calls[0][0])).toBe('{"stageField":"stage"}');
289+
});
290+
});

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

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { describe, it, expect, vi } from 'vitest';
99
import { render, screen, fireEvent, within } from '@testing-library/react';
1010
import React from 'react';
11+
import { t } from '../metadata-admin/i18n';
1112
import { ObjectSettingsPanel } from './ObjectSettingsPanel';
1213

1314
const baseDraft = {
@@ -24,14 +25,23 @@ function renderPanel(draft: Record<string, unknown>, onPatch = vi.fn()) {
2425
return onPatch;
2526
}
2627

28+
/**
29+
* The OWD dials are the shared `Select` (objectui#11865): its options exist
30+
* only while it is open, so open it from the keyboard and return its list.
31+
*/
32+
async function openDial(testId: string) {
33+
fireEvent.keyDown(screen.getByTestId(testId), { key: 'ArrowDown' });
34+
return within(await screen.findByRole('listbox'));
35+
}
36+
2737
describe('ObjectSettingsPanel — record sharing (OWD)', () => {
28-
it('exposes the sharing model control with the four canonical OWD options', () => {
38+
it('exposes the sharing model control with the four canonical OWD options', async () => {
2939
renderPanel(baseDraft);
3040
// The section header is present.
3141
expect(screen.getByText('Record sharing (OWD)')).toBeTruthy();
3242
// Canonical OWD options are all offered (scoped to the internal dial —
3343
// the external D11 dial offers the same set).
34-
const internal = within(screen.getByTestId('owd-internal-select'));
44+
const internal = await openDial('owd-internal-select');
3545
expect(internal.getByRole('option', { name: 'Private — owner only' })).toBeTruthy();
3646
expect(
3747
internal.getByRole('option', { name: 'Public read — everyone reads, only the owner writes' }),
@@ -72,35 +82,40 @@ describe('ObjectSettingsPanel — record sharing (OWD)', () => {
7282
expect(desc.textContent).not.toMatch(/refused/i);
7383
});
7484

75-
it('patches sharingModel when a model is picked', () => {
85+
it('patches sharingModel when a model is picked', async () => {
7686
const onPatch = renderPanel(baseDraft);
77-
const select = screen.getByTestId('owd-internal-select') as HTMLSelectElement;
78-
fireEvent.change(select, { target: { value: 'private' } });
87+
fireEvent.click((await openDial('owd-internal-select')).getByRole('option', { name: 'Private — owner only' }));
7988
expect(onPatch).toHaveBeenCalledWith({ sharingModel: 'private' });
8089
});
8190

82-
it('clears sharingModel back to unset', () => {
91+
it('clears sharingModel back to unset', async () => {
8392
const onPatch = renderPanel({ ...baseDraft, sharingModel: 'private' });
84-
const select = screen.getByDisplayValue('Private — owner only') as HTMLSelectElement;
85-
fireEvent.change(select, { target: { value: '' } });
93+
expect(screen.getByTestId('owd-internal-select').textContent).toBe('Private — owner only');
94+
fireEvent.click(
95+
(await openDial('owd-internal-select')).getByRole('option', { name: t('engine.studio.settings.sharingUnset', 'en-US') }),
96+
);
8697
expect(onPatch).toHaveBeenCalledWith({ sharingModel: undefined });
8798
});
8899
});
89100

90101
describe('ObjectSettingsPanel — external OWD dial (ADR-0090 D11)', () => {
91-
it('renders the external dial defaulting to unset and patches externalSharingModel', () => {
102+
it('renders the external dial defaulting to unset and patches externalSharingModel', async () => {
92103
const onPatch = renderPanel(baseDraft);
93-
const select = screen.getByTestId('owd-external-select') as HTMLSelectElement;
94-
expect(select.value).toBe('');
95-
fireEvent.change(select, { target: { value: 'public_read' } });
104+
expect(screen.getByTestId('owd-external-select').textContent).toBe(t('engine.studio.settings.sharingExternalUnset', 'en-US'));
105+
fireEvent.click(
106+
(await openDial('owd-external-select')).getByRole('option', {
107+
name: 'Public read — everyone reads, only the owner writes',
108+
}),
109+
);
96110
expect(onPatch).toHaveBeenCalledWith({ externalSharingModel: 'public_read' });
97111
});
98112

99-
it('clears externalSharingModel back to unset', () => {
113+
it('clears externalSharingModel back to unset', async () => {
100114
const onPatch = renderPanel({ ...baseDraft, externalSharingModel: 'private' });
101-
const select = screen.getByTestId('owd-external-select') as HTMLSelectElement;
102-
expect(select.value).toBe('private');
103-
fireEvent.change(select, { target: { value: '' } });
115+
expect(screen.getByTestId('owd-external-select').textContent).toBe('Private — owner only');
116+
fireEvent.click(
117+
(await openDial('owd-external-select')).getByRole('option', { name: t('engine.studio.settings.sharingExternalUnset', 'en-US') }),
118+
);
104119
expect(onPatch).toHaveBeenCalledWith({ externalSharingModel: undefined });
105120
});
106121

0 commit comments

Comments
 (0)