From e02c4c116249f6b5eb3fbc9834c641bf752866b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:07:31 +0000 Subject: [PATCH] refactor(app-shell): Studio's object settings panel picks with the shared Select (objectui#11865) The object Settings tab's five browser-native select elements (internal and external sharing model, name field, lifecycle field, and the highlight fields' "add field") become the shared Radix Select, through one module-private SettingsPicker modelled on the validation editor's RulePicker: items carry their option's index, so the options whose value is '' need no empty SelectItem, and a stored value none of the options carries gets an item of its own. Each caller maps the picked value to the same patch as before. Pinned in ObjectSettingsPanel.sharedSelect-11865.test.tsx against literals read once on the pre-conversion panel. ObjectSettingsPanel.test.tsx and StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx now read the trigger, whose button value is '' whatever the dial holds. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .../11865-object-settings-shared-select.md | 15 + ...tSettingsPanel.sharedSelect-11865.test.tsx | 290 ++++++++++++++++++ .../ObjectSettingsPanel.test.tsx | 47 ++- .../studio-design/ObjectSettingsPanel.tsx | 215 ++++++++----- ...urface.servedDraftTakenAsIs-10765.test.tsx | 8 +- 5 files changed, 484 insertions(+), 91 deletions(-) create mode 100644 .changeset/11865-object-settings-shared-select.md create mode 100644 packages/app-shell/src/views/studio-design/ObjectSettingsPanel.sharedSelect-11865.test.tsx diff --git a/.changeset/11865-object-settings-shared-select.md b/.changeset/11865-object-settings-shared-select.md new file mode 100644 index 0000000000..e60ac27d4e --- /dev/null +++ b/.changeset/11865-object-settings-shared-select.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +--- + +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). + +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. + +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. + +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. + +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. + +**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. diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.sharedSelect-11865.test.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.sharedSelect-11865.test.tsx new file mode 100644 index 0000000000..1d706ac5a6 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.sharedSelect-11865.test.tsx @@ -0,0 +1,290 @@ +/** + * 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. + */ + +/** + * Studio's object settings panel picks with the shared `Select` (objectui#11865). + * + * The Settings tab picked the internal and external sharing model, the name + * field, the lifecycle field and the next highlight field with browser-native + * selects, beside the shared Radix `Select` the rest of Studio picks with. The + * card asks for one control for one kind of choice, surface by surface; this + * suite covers this panel's pickers. + * + * What is pinned: + * - each picker IS the primitive (a Radix combobox trigger), shows the + * object's value, and no native select is left; + * - the four labelled pickers keep the name their wrapping label gave the + * native control; the "add field" picker had no label and still has no + * name; + * - every option of every picker writes the `onPatch` payload the native + * control wrote, compared as JSON text, with the keys that hold + * `undefined` named (JSON text drops them). That covers each "not set", + * "auto-derived" and "auto-detect" option, which writes its key holding + * `undefined`, and "None", which writes `stageField: false`; + * - re-picking the current option writes nothing, and neither does + * "add field"; + * - read-only: each trigger is disabled, wears the primitive's own disabled + * look, and does not open; the "add field" picker is not drawn, as before; + * - a stored value no option carries is what the trigger shows; + * - the keyboard alone opens a picker and selects. + * + * DIRECTION, observed against the native control: every pin here is red + * there, because each one reads the pickers as the primitive's triggers. What + * makes the write pins guards of "the conversion changed nothing the panel + * writes" is the literal each compares against: a `change` event on the + * pre-conversion panel's native control wrote that same JSON, with the same + * `undefined` keys, read once on that component. The names were read there + * the same way. That probe's `change` event fired for the current option too, + * so the re-pick rows pin the primitive, not a browser reading of the native + * control. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { Select, SelectTrigger, SelectValue } from '@object-ui/components'; +import { t } from '../metadata-admin/i18n'; +import { ObjectSettingsPanel } from './ObjectSettingsPanel'; + +afterEach(cleanup); + +const en = (key: string) => t(key, 'en-US'); + +const FIELDS = { + name: { type: 'text', label: 'Name' }, + status: { type: 'select', label: 'Status' }, + stage: { type: 'select' }, + secret: { type: 'text', label: 'Secret', hidden: true }, + code: { label: 'Code' }, +}; + +/** An object with every picker set to one of its own options. */ +const DRAFT: Record = { + fields: FIELDS, + sharingModel: 'public_read', + externalSharingModel: 'private', + nameField: 'name', + stageField: 'status', + highlightFields: ['name'], +}; + +const PICKERS = [ + 'owd-internal-select', + 'owd-external-select', + 'name-field-select', + 'stage-field-select', + 'highlight-add-select', +] as const; + +/** The labelled pickers, by the i18n key of the label that names each. */ +const NAMED_BY: ReadonlyArray = [ + ['owd-internal-select', 'engine.studio.settings.sharingModel'], + ['owd-external-select', 'engine.studio.settings.sharingExternal'], + ['name-field-select', 'engine.studio.settings.nameField'], + ['stage-field-select', 'engine.studio.settings.stageField'], +]; + +function renderPanel(draft: Record = DRAFT, disabled?: boolean) { + const onPatch = vi.fn(); + const utils = render( + , + ); + return { ...utils, onPatch }; +} + +/** Open a picker from the keyboard and return the options it lists, in order. */ +async function openPicker(testId: string): Promise { + fireEvent.keyDown(screen.getByTestId(testId), { key: 'ArrowDown' }); + const listbox = await screen.findByRole('listbox'); + return within(listbox).getAllByRole('option'); +} + +async function pick(testId: string, label: string): Promise { + const options = await openPicker(testId); + const option = options.find((o) => o.textContent === label); + if (!option) throw new Error(`${testId} lists no "${label}": ${options.map((o) => o.textContent).join(' | ')}`); + fireEvent.click(option); +} + +/** The `disabled:` utilities the shared `SelectTrigger` wears. */ +function primitiveDisabledLook(): string[] { + const { getByRole, unmount } = render( + , + ); + const tokens = getByRole('combobox').className.split(/\s+/).filter((c) => c.startsWith('disabled:')); + unmount(); + return tokens; +} + +describe('the object settings pickers are the shared Select (objectui#11865)', () => { + it('renders each picker as the Radix combobox trigger, showing the object’s value', () => { + const { container } = renderPanel(); + expect(container.querySelector('select')).toBeNull(); + + const shown: Record = {}; + for (const id of PICKERS) { + const trigger = screen.getByTestId(id); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveAttribute('role', 'combobox'); + shown[id] = trigger.textContent ?? ''; + } + expect(shown).toEqual({ + 'owd-internal-select': en('engine.studio.settings.sharingPublicRead'), + 'owd-external-select': en('engine.studio.settings.sharingPrivate'), + 'name-field-select': 'Name (name)', + 'stage-field-select': 'Status (status)', + 'highlight-add-select': en('engine.studio.settings.addFieldOption'), + }); + }); + + it('each labelled picker keeps the name its label gave the native control', () => { + renderPanel(); + for (const [id, key] of NAMED_BY) { + expect(screen.getByRole('combobox', { name: en(key) }), id).toBe(screen.getByTestId(id)); + } + }); + + it('the "add field" picker had no label and still has no name', () => { + renderPanel(); + expect(screen.getByRole('combobox', { name: '' })).toBe(screen.getByTestId('highlight-add-select')); + }); + + it('"None" on the lifecycle field is what the trigger shows for stageField: false', () => { + renderPanel({ ...DRAFT, stageField: false }); + expect(screen.getByTestId('stage-field-select')).toHaveTextContent(en('engine.studio.settings.stageNone')); + }); +}); + +/** + * [picker, option label, the JSON text `onPatch` received, the keys of the + * patch that hold `undefined`]. `null`: nothing is written — the option is the + * current one, or it is "add field". + */ +const WRITES: ReadonlyArray = [ + ['owd-internal-select', en('engine.studio.settings.sharingUnset'), '{}', ['sharingModel']], + ['owd-internal-select', en('engine.studio.settings.sharingPrivate'), '{"sharingModel":"private"}', []], + ['owd-internal-select', en('engine.studio.settings.sharingPublicRead'), null, []], + ['owd-internal-select', en('engine.studio.settings.sharingPublicReadWrite'), '{"sharingModel":"public_read_write"}', []], + ['owd-internal-select', en('engine.studio.settings.sharingControlledByParent'), '{"sharingModel":"controlled_by_parent"}', []], + ['owd-external-select', en('engine.studio.settings.sharingExternalUnset'), '{}', ['externalSharingModel']], + ['owd-external-select', en('engine.studio.settings.sharingPrivate'), null, []], + ['owd-external-select', en('engine.studio.settings.sharingPublicRead'), '{"externalSharingModel":"public_read"}', []], + ['owd-external-select', en('engine.studio.settings.sharingPublicReadWrite'), '{"externalSharingModel":"public_read_write"}', []], + ['owd-external-select', en('engine.studio.settings.sharingControlledByParent'), '{"externalSharingModel":"controlled_by_parent"}', []], + ['name-field-select', en('engine.studio.settings.autoDerive'), '{}', ['nameField']], + ['name-field-select', 'Name (name)', null, []], + ['name-field-select', 'Status (status)', '{"nameField":"status"}', []], + ['name-field-select', 'stage', '{"nameField":"stage"}', []], + ['name-field-select', 'Secret (secret)', '{"nameField":"secret"}', []], + ['name-field-select', 'Code (code)', '{"nameField":"code"}', []], + ['stage-field-select', en('engine.studio.settings.autoDetect'), '{}', ['stageField']], + ['stage-field-select', en('engine.studio.settings.stageNone'), '{"stageField":false}', []], + ['stage-field-select', 'Status (status)', null, []], + ['stage-field-select', 'stage', '{"stageField":"stage"}', []], + ['highlight-add-select', en('engine.studio.settings.addFieldOption'), null, []], + ['highlight-add-select', 'Status (status)', '{"highlightFields":["name","status"]}', []], + ['highlight-add-select', 'stage', '{"highlightFields":["name","stage"]}', []], + ['highlight-add-select', 'Code (code)', '{"highlightFields":["name","code"]}', []], +]; + +describe('every option writes what the native control wrote', () => { + it('the table covers every option of every picker, in the order each lists them', async () => { + for (const id of PICKERS) { + renderPanel(); + const listed = (await openPicker(id)).map((o) => o.textContent); + expect(listed, id).toEqual(WRITES.filter(([p]) => p === id).map(([, label]) => label)); + cleanup(); + } + }); + + it.each(WRITES.map((row) => [`${row[0]} → ${row[1]}`, ...row] as const))( + '%s', + async (_name, testId, label, json, undefinedKeys) => { + const { onPatch } = renderPanel(); + await pick(testId, label); + if (json === null) { + expect(onPatch).not.toHaveBeenCalled(); + return; + } + expect(onPatch).toHaveBeenCalledTimes(1); + const patch = onPatch.mock.calls[0][0] as Record; + expect(JSON.stringify(patch)).toBe(json); + expect(Object.keys(patch).filter((k) => patch[k] === undefined)).toEqual(undefinedKeys); + }, + ); +}); + +describe('read-only follows the primitive (objectui#11781)', () => { + it('each picker is disabled, wears the primitive’s disabled look, and does not open', () => { + const look = primitiveDisabledLook(); + expect(look.length, 'the primitive carries no disabled look to compare with').toBeGreaterThan(0); + const { onPatch } = renderPanel(DRAFT, true); + for (const [id] of NAMED_BY) { + const trigger = screen.getByTestId(id); + expect(trigger).toBeDisabled(); + for (const token of look) expect(trigger, `${id} lacks ${token}`).toHaveClass(token); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + expect(screen.queryByRole('listbox'), `${id} opened while read-only`).toBeNull(); + } + // As before the conversion, read-only draws no "add field" picker at all. + expect(screen.queryByTestId('highlight-add-select')).toBeNull(); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it('CONTROL — writable: the same pickers are drawn and enabled', () => { + renderPanel(); + for (const id of PICKERS) expect(screen.getByTestId(id)).toBeEnabled(); + }); +}); + +describe('a stored value no option carries is what the trigger shows', () => { + it('a name field the object does not have: shown and listed first, and re-picking it writes nothing', async () => { + const { onPatch } = renderPanel({ ...DRAFT, nameField: 'ghost' }); + // The native control showed "(auto-derived)" here, as if no name field were set. + expect(screen.getByTestId('name-field-select')).toHaveTextContent('ghost'); + const listed = (await openPicker('name-field-select')).map((o) => o.textContent); + expect(listed).toEqual([ + 'ghost', + en('engine.studio.settings.autoDerive'), + 'Name (name)', + 'Status (status)', + 'stage', + 'Secret (secret)', + 'Code (code)', + ]); + fireEvent.click(screen.getAllByRole('option')[0]); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it('a lifecycle field that is not a select field is shown, not "auto-detect"', () => { + renderPanel({ ...DRAFT, stageField: 'name' }); + expect(screen.getByTestId('stage-field-select')).toHaveTextContent('name'); + }); + + it('a sharing model neither dial offers is shown, not "not set"', () => { + renderPanel({ ...DRAFT, sharingModel: 'full', externalSharingModel: 'read_write' }); + expect(screen.getByTestId('owd-internal-select')).toHaveTextContent('full'); + expect(screen.getByTestId('owd-external-select')).toHaveTextContent('read_write'); + }); +}); + +describe('the keyboard alone picks', () => { + it('Enter opens a picker and Enter on an option selects it', async () => { + const { onPatch } = renderPanel(); + fireEvent.keyDown(screen.getByTestId('stage-field-select'), { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: 'stage' }), { key: 'Enter' }); + expect(onPatch).toHaveBeenCalledTimes(1); + expect(JSON.stringify(onPatch.mock.calls[0][0])).toBe('{"stageField":"stage"}'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx index 343e0154b1..4be4af6433 100644 --- a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent, within } from '@testing-library/react'; import React from 'react'; +import { t } from '../metadata-admin/i18n'; import { ObjectSettingsPanel } from './ObjectSettingsPanel'; const baseDraft = { @@ -24,14 +25,23 @@ function renderPanel(draft: Record, onPatch = vi.fn()) { return onPatch; } +/** + * The OWD dials are the shared `Select` (objectui#11865): its options exist + * only while it is open, so open it from the keyboard and return its list. + */ +async function openDial(testId: string) { + fireEvent.keyDown(screen.getByTestId(testId), { key: 'ArrowDown' }); + return within(await screen.findByRole('listbox')); +} + describe('ObjectSettingsPanel — record sharing (OWD)', () => { - it('exposes the sharing model control with the four canonical OWD options', () => { + it('exposes the sharing model control with the four canonical OWD options', async () => { renderPanel(baseDraft); // The section header is present. expect(screen.getByText('Record sharing (OWD)')).toBeTruthy(); // Canonical OWD options are all offered (scoped to the internal dial — // the external D11 dial offers the same set). - const internal = within(screen.getByTestId('owd-internal-select')); + const internal = await openDial('owd-internal-select'); expect(internal.getByRole('option', { name: 'Private — owner only' })).toBeTruthy(); expect( internal.getByRole('option', { name: 'Public read — everyone reads, only the owner writes' }), @@ -72,35 +82,40 @@ describe('ObjectSettingsPanel — record sharing (OWD)', () => { expect(desc.textContent).not.toMatch(/refused/i); }); - it('patches sharingModel when a model is picked', () => { + it('patches sharingModel when a model is picked', async () => { const onPatch = renderPanel(baseDraft); - const select = screen.getByTestId('owd-internal-select') as HTMLSelectElement; - fireEvent.change(select, { target: { value: 'private' } }); + fireEvent.click((await openDial('owd-internal-select')).getByRole('option', { name: 'Private — owner only' })); expect(onPatch).toHaveBeenCalledWith({ sharingModel: 'private' }); }); - it('clears sharingModel back to unset', () => { + it('clears sharingModel back to unset', async () => { const onPatch = renderPanel({ ...baseDraft, sharingModel: 'private' }); - const select = screen.getByDisplayValue('Private — owner only') as HTMLSelectElement; - fireEvent.change(select, { target: { value: '' } }); + expect(screen.getByTestId('owd-internal-select').textContent).toBe('Private — owner only'); + fireEvent.click( + (await openDial('owd-internal-select')).getByRole('option', { name: t('engine.studio.settings.sharingUnset', 'en-US') }), + ); expect(onPatch).toHaveBeenCalledWith({ sharingModel: undefined }); }); }); describe('ObjectSettingsPanel — external OWD dial (ADR-0090 D11)', () => { - it('renders the external dial defaulting to unset and patches externalSharingModel', () => { + it('renders the external dial defaulting to unset and patches externalSharingModel', async () => { const onPatch = renderPanel(baseDraft); - const select = screen.getByTestId('owd-external-select') as HTMLSelectElement; - expect(select.value).toBe(''); - fireEvent.change(select, { target: { value: 'public_read' } }); + expect(screen.getByTestId('owd-external-select').textContent).toBe(t('engine.studio.settings.sharingExternalUnset', 'en-US')); + fireEvent.click( + (await openDial('owd-external-select')).getByRole('option', { + name: 'Public read — everyone reads, only the owner writes', + }), + ); expect(onPatch).toHaveBeenCalledWith({ externalSharingModel: 'public_read' }); }); - it('clears externalSharingModel back to unset', () => { + it('clears externalSharingModel back to unset', async () => { const onPatch = renderPanel({ ...baseDraft, externalSharingModel: 'private' }); - const select = screen.getByTestId('owd-external-select') as HTMLSelectElement; - expect(select.value).toBe('private'); - fireEvent.change(select, { target: { value: '' } }); + expect(screen.getByTestId('owd-external-select').textContent).toBe('Private — owner only'); + fireEvent.click( + (await openDial('owd-external-select')).getByRole('option', { name: t('engine.studio.settings.sharingExternalUnset', 'en-US') }), + ); expect(onPatch).toHaveBeenCalledWith({ externalSharingModel: undefined }); }); diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx index 305ffefdda..b912f05b53 100644 --- a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx @@ -41,11 +41,101 @@ import React from 'react'; import { Settings2, ShieldCheck, Sparkles, ToggleRight, X } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@object-ui/components'; import { useRegisteredMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js'; -import { readFields } from '../metadata-admin/previews/object-fields-io.js'; +import { readFields, type FieldEntry } from '../metadata-admin/previews/object-fields-io.js'; import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n.js'; import { isExternalWider } from './owd-sharing.js'; +/** The item a stored value none of a picker's options carries is shown by. */ +const OUTSIDE_OPTIONS = 'outside'; + +/** + * objectui#11865 — one of this panel's pickers (the internal and external + * sharing model, the name field, the lifecycle field, and the highlight + * fields' "add field"), drawn with the shared `Select`, the control the + * validation rule editor's pickers (`RulePicker`) pick with. They used to be + * browser-native ` { + // `undefined` for the outside item: it is the stored value, so there is nothing to write. + const picked = options[Number(token)]; + if (picked) onPick(picked.value); + }} + disabled={disabled} + > + + + + + {at === -1 && {value}} + {options.map((o, i) => ( + + {o.label} + + ))} + + + ); +} + +/** The classes a full-width picker takes, at the height the native control had. */ +const PICKER_FULL = 'h-7 px-2 py-1 text-[12px]'; + +/** The classes the inline "add field" picker takes, sized to its label as the native control was. */ +const PICKER_INLINE = 'h-6 w-auto gap-1 px-1.5 py-0.5 text-[11px] text-muted-foreground'; + +/** A field as a picker option: its label and name, or its name alone. */ +function fieldOption(e: FieldEntry): { value: string; label: string } { + return { + value: e.name, + label: typeof e.def.label === 'string' ? `${e.def.label} (${e.name})` : e.name, + }; +} + export function ObjectSettingsPanel({ name, draft, @@ -103,6 +193,14 @@ export function ObjectSettingsPanel({ : sharingModel === 'controlled_by_parent' ? 'engine.studio.settings.sharingDescControlledByParent' : 'engine.studio.settings.sharingDescUnset'; + // Both dials offer the same models; only the unset option's wording differs. + const owdOptions = (unsetKey: string) => [ + { value: '', label: t(unsetKey, locale) }, + { value: 'private', label: t('engine.studio.settings.sharingPrivate', locale) }, + { value: 'public_read', label: t('engine.studio.settings.sharingPublicRead', locale) }, + { value: 'public_read_write', label: t('engine.studio.settings.sharingPublicReadWrite', locale) }, + { value: 'controlled_by_parent', label: t('engine.studio.settings.sharingControlledByParent', locale) }, + ]; // Capabilities (`enable.*`, framework#2707/#2727). Checked = the flag's // EFFECTIVE runtime value; toggling writes an explicit boolean into the @@ -175,23 +273,14 @@ export function ObjectSettingsPanel({ {t('engine.studio.settings.sharingModel', locale)} - + onPick={(v) => onPatch(v ? { sharingModel: v } : { sharingModel: undefined })} + className={PICKER_FULL} + options={owdOptions('engine.studio.settings.sharingUnset')} + /> {/* An UNSET internal OWD is not a neutral state — the publish door refuses it (`security-owd-unset`). Styled as the problem it is, @@ -213,27 +302,16 @@ export function ObjectSettingsPanel({ {t('engine.studio.settings.sharingExternal', locale)} - + className={PICKER_FULL} + options={owdOptions('engine.studio.settings.sharingExternalUnset')} + />

{t('engine.studio.settings.nameField', locale)} - + onPick={(v) => onPatch(v ? { nameField: v } : { nameField: undefined })} + className={PICKER_FULL} + options={[ + { value: '', label: t('engine.studio.settings.autoDerive', locale) }, + ...fields.map(fieldOption), + ]} + /> {/* stageField */} @@ -287,23 +363,18 @@ export function ObjectSettingsPanel({ {t('engine.studio.settings.stageField', locale)} - + onPick={(v) => onPatch({ stageField: v === '__none__' ? false : v === '' ? undefined : v })} + className={PICKER_FULL} + options={[ + { value: '', label: t('engine.studio.settings.autoDetect', locale) }, + { value: '__none__', label: t('engine.studio.settings.stageNone', locale) }, + ...selectFields.map(fieldOption), + ]} + /> {/* highlightFields */} @@ -331,21 +402,19 @@ export function ObjectSettingsPanel({ ))} {!disabled && highlightCandidates.length > 0 && ( - + className={PICKER_INLINE} + options={[ + { value: '', label: t('engine.studio.settings.addFieldOption', locale) }, + ...highlightCandidates.map(fieldOption), + ]} + /> )} {highlightFields.length === 0 && ( {t('engine.studio.settings.undeclared', locale)} diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx index 4f4a6b0bdf..fd80237168 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx @@ -28,6 +28,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; +import { t } from '../metadata-admin/i18n'; const PKG = 'com.acme.app'; @@ -216,9 +217,12 @@ describe('StudioDesignSurface — every load site takes the served draft as-is, await userEvent.click(advanced); await userEvent.click(await screen.findByRole('menuitem', { name: 'Settings' })); - const internal = (await screen.findByTestId('owd-internal-select', undefined, { timeout: 8000 })) as HTMLSelectElement; + // The dial is the shared Select's trigger (objectui#11865), so what it shows + // is its text: a button's `value` is '' whatever the dial holds. + const internal = await screen.findByTestId('owd-internal-select', undefined, { timeout: 8000 }); // Red on base: the spread over `effective` showed `private` from the published row. - expect(internal.value).toBe(''); + expect(internal).toHaveTextContent(t('engine.studio.settings.sharingUnset', 'en-US')); + expect(internal).not.toHaveTextContent(t('engine.studio.settings.sharingPrivate', 'en-US')); }); it('Automations pillar: the flow draft is the served draft, and the enable switch saves exactly that draft — no published key comes back', async () => {