From 3019cff252b62fede567fb3e2c9fa08b501d5899 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:01:59 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(app-shell):=20Studio=20uses=20shared?= =?UTF-8?q?=20picklists=20=E2=80=94=20select-field=20picker,=20served=20op?= =?UTF-8?q?tions=20left=20out=20of=20object=20saves,=20read-only=20picklis?= =?UTF-8?q?t=20page=20(objectui#10202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ObjectFieldInspector: an "Options from" picker over the picklists the runtime serves (GET /meta/picklist). Choosing one sets `picklist` and removes `options`; a bound field shows the list's values read-only and offers no inline options editor; a retype to a non-option type (spec SINGLE_OPTION_TYPES / MULTI_OPTION_TYPES) drops the binding; the default value picker offers the list's values. - dropServedPicklistOptions: the served -> authored conversion. Every picklist-bound field leaves without its resolved `options`; nothing else is touched. Applied as the `object` resource's `fromDraft` (metadata-admin designer) and at the Studio data page's two object PUTs (autosave, column reorder). The metadata-admin live Zod pass judges the `fromDraft` body. - PicklistPreview: the read-only detail view of the package-owned kind — label, name, description, owning package, options, and the `picklistExtensions` other packages declare (read from GET /meta/package). Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/10202-studio-use-picklist.md | 13 + .../src/services/builtinComponents.tsx | 8 + ...ceEditPage.picklistReadOnly-10202.test.tsx | 145 +++++++++++ ...tPage.picklistServedOptions-10202.test.tsx | 222 +++++++++++++++++ .../views/metadata-admin/ResourceEditPage.tsx | 21 +- .../src/views/metadata-admin/i18n.ts | 42 ++++ .../inspectors/ObjectFieldInspector.tsx | 199 ++++++++++++++- ...tFieldInspector.usePicklist-10202.test.tsx | 218 +++++++++++++++++ .../metadata-admin/picklist-binding.test.ts | 117 +++++++++ .../views/metadata-admin/picklist-binding.ts | 87 +++++++ .../previews/PicklistPreview.test.tsx | 151 ++++++++++++ .../previews/PicklistPreview.tsx | 227 ++++++++++++++++++ .../views/metadata-admin/previews/index.ts | 5 + ...illar.picklistServedOptions-10202.test.tsx | 205 ++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 9 +- 15 files changed, 1662 insertions(+), 7 deletions(-) create mode 100644 .changeset/10202-studio-use-picklist.md create mode 100644 packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistReadOnly-10202.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistServedOptions-10202.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/picklist-binding.test.ts create mode 100644 packages/app-shell/src/views/metadata-admin/picklist-binding.ts create mode 100644 packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx create mode 100644 packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx diff --git a/.changeset/10202-studio-use-picklist.md b/.changeset/10202-studio-use-picklist.md new file mode 100644 index 0000000000..51cb75a933 --- /dev/null +++ b/.changeset/10202-studio-use-picklist.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': minor +--- + +Studio reads shared picklists: a select field can use one, an object with a picklist-bound field saves again, and the picklist page is read-only (objectui#10202, the objectui half of objectstack#18164 phase 2). + +A shared picklist is one list of options that select fields on several objects name with `picklist: 'NAME'` instead of carrying their own (`@objectstack/spec` 17.6.0, `data/picklist.zod.ts`). The kind is package-owned: it is authored in a package, and the runtime takes no create, edit or delete for it. + +- **"Use picklist" in the select-field editor.** For a `select`, `radio`, `multiselect` or `checkboxes` field, an "Options from" picker lists the picklists the runtime serves, by name, beside "This field's own options". Choosing a picklist sets `picklist` and removes the field's `options`. A bound field shows the list's values read-only, and offers no inline options editor, because the spec refuses `picklist` together with `options`. Its default value is chosen from the list's values. Changing the field to a type that takes no options drops the binding. A stored name the served list no longer has is shown flagged as not found. If the list cannot be loaded, the picker says so and still offers the field's own options. +- **Saving an object with a picklist-bound field works again.** The runtime serves such a field with the options it resolved from the list and its extensions. The metadata-admin object designer and the Studio data page sent those back, and the save was refused with `422 INVALID_METADATA` whatever had been edited. Both now leave `options` out of every field that names a picklist. Every other field and key is sent as it was. The metadata-admin designer's live check now judges the body it sends, so it no longer reports "`picklist` and `options` cannot both be declared" on such an object. +- **The picklist page is read-only.** Its detail view shows the list's label, name, description and owning package, each option with its label and value, and the options other packages add to it (`picklistExtensions`), each under the package that declares it. It offers no create, edit or delete. The list page already offered no create for this kind. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. The new copy lives in the metadata-admin designer's own string tables (en and zh). diff --git a/packages/app-shell/src/services/builtinComponents.tsx b/packages/app-shell/src/services/builtinComponents.tsx index a4823f9ae2..373b3a8cf3 100644 --- a/packages/app-shell/src/services/builtinComponents.tsx +++ b/packages/app-shell/src/services/builtinComponents.tsx @@ -25,6 +25,7 @@ import { isAggregatedViewContainer, viewDisplayType, } from '../views/metadata-admin/view-item-normalize.js'; +import { dropServedPicklistOptions } from '../views/metadata-admin/picklist-binding.js'; /* -------------------------------------------------------------------------- */ /* 1) Top-level admin pages — bound to `metadata:directory` + `metadata:resource` */ @@ -158,6 +159,13 @@ registerMetadataResource({ { key: 'label', label: 'Label', width: '25%' }, { key: 'description', label: 'Description' }, ], + // objectui#10202 — the editor is seeded from the SERVED object, where a + // picklist-bound field carries `picklist` plus the options resolved from the + // list; the authoring door refuses the two together. The saved body drops + // those resolved `options` from every picklist-bound field and touches + // nothing else. The draft is not changed, so the canvas keeps showing the + // list's options. See `dropServedPicklistOptions`. + fromDraft: dropServedPicklistOptions, }); registerMetadataResource({ diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistReadOnly-10202.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistReadOnly-10202.test.tsx new file mode 100644 index 0000000000..3f843d30e9 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistReadOnly-10202.test.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10202 — the Studio picklist page is READ-ONLY, through the generic + * resource route (ruling 6006062645 item (c)). + * + * The kind is package-owned: its `/meta/types` row declares + * `allowRuntimeCreate: false` and `allowOrgOverride: false`, and the layered + * read of a packaged list answers `lock: 'full'`, `editable: false`, + * `deletable: false` (all measured on objectstack `main`). The generic list and + * edit pages already honour that row, so this card adds no read-only mechanism + * of its own: it registers the detail view (`PicklistPreview`) and pins that + * the page built from the two offers no create, no edit and no delete — the + * three things the server refuses for this kind. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +type Row = Record; + +/** The `/meta/types` row for `picklist`, as the runtime serves it. */ +const PICKLIST_ENTRY = { + type: 'picklist', + label: 'Picklist', + description: 'Shared option list that select fields reference by name', + supportsOverlay: false, + allowOrgOverride: false, + allowRuntimeCreate: false, + supportsVersioning: false, + domain: 'data', + schema: { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' } } }, +}; + +const LIST: Row = { + name: 'showcase_priority_tier', + label: 'Priority Tier', + options: [ + { label: 'Gold', value: 'gold' }, + { label: 'Silver', value: 'silver' }, + ], + _packageId: 'com.example.showcase', + _provenance: 'package', +}; + +const PACKAGE_ROW = { manifest: { id: 'com.example.showcase', name: 'ObjectStack Showcase', scope: 'project' } }; + +const writes = vi.hoisted(() => [] as string[]); + +const client = vi.hoisted(() => ({ + list: async (type: string) => (type === 'picklist' ? [LIST] : type === 'package' ? [PACKAGE_ROW] : []), + listDrafts: async () => [], + get: async () => LIST, + references: async () => [], + layered: async () => ({ + code: LIST, + overlay: null, + overlayScope: null, + effective: LIST, + lock: 'full', + provenance: 'package', + packageId: 'com.example.showcase', + editable: false, + deletable: false, + resettable: true, + }), + getDraft: async () => null, + save: async (type: string, name: string) => { + writes.push(`save ${type}/${name}`); + return {}; + }, + reset: async (type: string, name: string) => { + writes.push(`reset ${type}/${name}`); + return {}; + }, + publish: async (type: string, name: string) => { + writes.push(`publish ${type}/${name}`); + return {}; + }, +})); + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client, + useMetadataTypes: () => ({ entries: [PICKLIST_ENTRY] }), + }; +}); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { MetadataResourceListPage } from './ResourceListPage'; +import './register-builtins'; +import '../../services/builtinComponents.js'; + +// Pre-declared so a regression to an enabled button or a link reads by name. +const WRITE_VERB = /^(new|create|save|edit|delete|reset|publish|add)\b/i; + +afterEach(() => { + cleanup(); + writes.length = 0; +}); + +describe('Studio picklist page — read-only through the generic route (objectui#10202)', () => { + it('the list offers no create', async () => { + render( + + + } /> + + , + ); + expect(await screen.findByText('showcase_priority_tier', {}, { timeout: 8000 })).toBeInTheDocument(); + const offered = screen.queryAllByRole('button').filter((b) => WRITE_VERB.test((b.textContent ?? '').trim())); + expect(offered.map((b) => b.textContent)).toEqual([]); + }); + + it('the detail page shows the list through its preview, and offers no edit or delete', async () => { + render( + + + , + ); + const options = await screen.findByRole('region', { name: 'Options' }, { timeout: 8000 }); + expect(within(options).getByText('Gold')).toBeInTheDocument(); + expect(within(options).getByText('Silver')).toBeInTheDocument(); + + // Every enabled control's name, and every write verb among them. + const enabledWrites = screen + .queryAllByRole('button') + .filter((b) => !(b as HTMLButtonElement).disabled) + .map((b) => (b.getAttribute('aria-label') || b.getAttribute('title') || b.textContent || '').trim()) + .filter((n) => WRITE_VERB.test(n)); + expect(enabledWrites).toEqual([]); + expect(writes).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistServedOptions-10202.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistServedOptions-10202.test.tsx new file mode 100644 index 0000000000..e6e589ae3d --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.picklistServedOptions-10202.test.tsx @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10202 — the metadata-admin object designer saves an object whose + * fields name a shared picklist. + * + * ## The defect this file keeps fixed + * + * The runtime serves a picklist-bound field with `picklist` AND the options it + * resolved from the list and its extensions (`PicklistServedFieldSchema`), and + * the designer seeds its draft from that served read (`layers.effective`). The + * authoring door refuses the two keys together, so before this card ANY edit of + * such an object — renaming an unrelated field's label — PUT the served pair + * back and came back `422 INVALID_METADATA` at `fields.FIELD.options`. Measured + * on objectstack `main` (`faf8dce4`) with a picklist-bound field on a showcase + * object: `PUT /api/v1/meta/object/NAME?mode=draft` with the served body → + * 422, the issue path `fields.priority_tier.options`. + * + * The page is the real `MetadataResourceEditPage` with its registered object + * canvas, field inspector and `object` resource config. The client is a real + * `MetadataClient` (its `save` is the door that runs the write guard); only its + * transport and the mount's reads are doubles. The transport refuses a body the + * way the authoring door does — any field carrying `picklist` beside `options` + * — so the claim is read off the WIRE. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +type Row = Record; + +/** The account object as the runtime SERVES it: `tier` resolved from its picklist, `status` inline. */ +const SERVED: Row = { + name: 'acme_account', + label: 'Account', + fields: { + title: { type: 'text', label: 'Title' }, + tier: { + type: 'select', + label: 'Tier', + picklist: 'acme_tier', + options: [ + { label: 'Gold', value: 'gold' }, + { label: 'Silver', value: 'silver' }, + { label: 'Platinum', value: 'platinum' }, + ], + }, + regions: { + type: 'multiselect', + label: 'Regions', + picklist: 'acme_region', + options: [{ label: 'EU', value: 'eu' }], + }, + status: { + type: 'select', + label: 'Status', + options: [ + { label: 'Open', value: 'open', color: '#22c55e' }, + { label: 'Closed', value: 'closed' }, + ], + }, + }, +}; + +const OBJECT_ENTRY = { + type: 'object', + name: 'object', + label: 'Object', + allowOrgOverride: true, + schema: { type: 'object', properties: { label: { type: 'string', title: 'Label' } } }, +}; + +/** The authoring door's refusal for a field carrying both keys, as `FieldSchema` words its path. */ +function doorRefusal(body: Row): Array<{ path: string; message: string; code: string }> { + const fields = (body.fields ?? {}) as Record; + return Object.entries(fields) + .filter(([, def]) => def && def.picklist !== undefined && def.options !== undefined) + .map(([name]) => ({ + path: `fields.${name}.options`, + message: '`picklist` and `options` cannot both be declared', + code: 'custom', + })); +} + +const puts: Array<{ url: string; body: Row; status: number }> = []; + +const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Row; + const issues = doorRefusal(body); + const status = issues.length > 0 ? 422 : 200; + puts.push({ url, body, status }); + return status === 422 + ? new Response(JSON.stringify({ error: 'failed spec validation', code: 'INVALID_METADATA', issues }), { + status: 422, + headers: { 'content-type': 'application/json' }, + }) + : new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }); +}); + +/** The draft the server now holds: the last ACCEPTED PUT, or none. */ +const storedDraft = (): Row | null => { + const ok = puts.filter((p) => p.status === 200); + return ok.length > 0 ? ok[ok.length - 1].body : null; +}; + +const client = new MetadataClient({ baseUrl: 'http://test.local', fetch: fetchImpl as unknown as typeof fetch }); +Object.assign(client, { + list: vi.fn(async (type: string) => + type === 'picklist' + ? [ + { name: 'acme_tier', label: 'Tier', options: [{ label: 'Gold', value: 'gold' }, { label: 'Silver', value: 'silver' }] }, + { name: 'acme_region', label: 'Region', options: [{ label: 'EU', value: 'eu' }] }, + ] + : [], + ), + listDrafts: vi.fn(async () => []), + get: vi.fn(async () => SERVED), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: SERVED, code: SERVED, editable: true })), + getDraft: vi.fn(async () => { + const d = storedDraft(); + return d ? { item: d } : null; + }), +}); + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client, + useMetadataTypes: () => ({ entries: [OBJECT_ENTRY] }), + }; +}); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { getMetadataResource } from './registry'; +// The load-time registrations and the built-in resource configs, exactly as the +// package entry runs them. +import './register-builtins'; +import '../../services/builtinComponents.js'; + +afterEach(() => { + cleanup(); + puts.length = 0; + fetchImpl.mockClear(); +}); + +async function outlastDebounce(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); +} + +function renderDesigner() { + render( + + + , + ); +} + +/** Select `title` on the canvas and rename its label — an edit that touches no bound field. */ +async function renameTitleLabel(next: string) { + const card = (await screen.findAllByText('Title', {}, { timeout: 8000 })) + .map((el) => el.closest('[role="button"]')) + .find((el): el is HTMLElement => !!el); + expect(card, 'the `title` field is on the canvas').toBeTruthy(); + fireEvent.click(card!); + const input = (await screen.findByTestId('field-label-input', {}, { timeout: 8000 })) as HTMLInputElement; + fireEvent.change(input, { target: { value: next } }); +} + +describe('metadata-admin object designer — a picklist-bound field saves without its served options (objectui#10202)', () => { + it('THE PIN: an edit of an unrelated field saves; no bound field carries `options`, every other field is byte-identical', async () => { + renderDesigner(); + await renameTitleLabel('Headline'); + await outlastDebounce(); + await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 4000 }); + + const sent = puts[puts.length - 1]; + expect(sent.status, `refused: ${JSON.stringify(doorRefusal(sent.body))}`).toBe(200); + const fields = sent.body.fields as Record; + // Every bound field leaves without `options` — not only the edited one. + expect(fields.tier).toEqual({ type: 'select', label: 'Tier', picklist: 'acme_tier' }); + expect(fields.regions).toEqual({ type: 'multiselect', label: 'Regions', picklist: 'acme_region' }); + // Strip nothing else: the inline select keeps its options byte-identical. + expect(JSON.stringify(fields.status)).toBe(JSON.stringify((SERVED.fields as Record).status)); + // The edit itself went out. + expect(fields.title).toEqual({ type: 'text', label: 'Headline' }); + expect(puts.every((p) => p.status === 200)).toBe(true); + }); + + it('the live check does not tell the author to fix the served pair the save never sends', async () => { + renderDesigner(); + await screen.findAllByText('Title', {}, { timeout: 8000 }); + await act(async () => { + await new Promise((r) => setTimeout(r, 600)); + }); + expect(screen.queryByText(/cannot both be declared/)).toBeNull(); + }); + + it('the registered `fromDraft` is the strip, and it leaves the editor draft untouched', () => { + const fromDraft = getMetadataResource('object')?.fromDraft; + expect(fromDraft).toBeTypeOf('function'); + const draft = JSON.parse(JSON.stringify(SERVED)) as Row; + const body = fromDraft!(draft); + expect((body.fields as Record).tier).not.toHaveProperty('options'); + expect((draft.fields as Record).tier).toHaveProperty('options'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 06fcc07408..145124f8fe 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -606,9 +606,26 @@ function MetadataResourceEditPageImpl({ // schema the server runs. Judging a stored body by the authoring schema // made this editor reject bodies the server accepts — e.g. a view that // had been pinned or reordered carries `isPinned` / `sortOrder`. + // + // objectui#10202 — in edit mode the pass judges the body the save SENDS, + // `fromDraft(draft)`, the same serialiser `doSave` applies before + // `client.save`. A key that serialiser drops is not the author's to fix: + // an object's served picklist options (the door refuses them beside + // `picklist`) used to surface here as "`picklist` and `options` cannot + // both be declared" on a draft whose save sends neither pair. Read from + // the registry by `type`, so no memoised identity keys this effect. + const fromDraft = createMode ? undefined : getMetadataResource(type)?.fromDraft; + let judged: Record = draft; + if (fromDraft) { + try { + judged = fromDraft(draft); + } catch { + judged = draft; + } + } void validateMetadataDraft( type, - draft, + judged, entry?.schema as { required?: unknown } | undefined, { mode: draftMode }, ).then((res) => { @@ -620,7 +637,7 @@ function MetadataResourceEditPageImpl({ cancelled = true; window.clearTimeout(handle); }; - }, [type, draft, entry?.schema, draftMode]); + }, [type, draft, entry?.schema, draftMode, createMode]); // Issues to DISPLAY (banner + inline). Suppressed on a pristine create form // so a blank new item doesn't open covered in required-field errors. const displayIssues = React.useMemo( diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 3ab83b8986..5178e02acd 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -112,6 +112,7 @@ const TYPE_LABELS_EN: Record = { theme: 'Theme', sharing_rule: 'Sharing Rule', analytics_cube: 'Analytics Cube', + picklist: 'Picklist', }; const TYPE_LABELS_ZH: Record = { @@ -154,6 +155,7 @@ const TYPE_LABELS_ZH: Record = { theme: '主题', sharing_rule: '共享规则', analytics_cube: '分析立方体', + picklist: '选项列表', }; const DOMAIN_LABELS_EN: Record = { @@ -1606,6 +1608,22 @@ const ENGINE_STRINGS_EN: Record = { 'engine.positionPreview.bind': 'Bind {name} to one or more {permissionSets} to control CRUD-VAMA, field access, and tab visibility. Record visibility comes from the business-unit tree and sharing rules, not from the position.', 'engine.positionPreview.permissionSets': 'Permission Sets', + // objectui#10202 — the read-only picklist page (the kind is package-owned). + 'engine.picklistPreview.empty': 'This picklist has no name.', + 'engine.picklistPreview.readOnly': + 'Read-only: a picklist is owned by the package that declares it, and is not created, edited or deleted here.', + 'engine.picklistPreview.ownedBy': 'Owning package', + 'engine.picklistPreview.options': 'Options', + 'engine.picklistPreview.colLabel': 'Label', + 'engine.picklistPreview.colValue': 'Value', + 'engine.picklistPreview.noOptions': 'This picklist declares no options.', + 'engine.picklistPreview.extensions': 'Extensions', + 'engine.picklistPreview.extensionsHint': 'Options other packages add to this picklist.', + 'engine.picklistPreview.addedBy': 'Added by {package}', + 'engine.picklistPreview.noExtensions': 'No package extends this picklist.', + 'engine.picklistPreview.extensionsLoading': 'Loading extensions…', + 'engine.picklistPreview.extensionsFailed': + 'The installed packages could not be read, so the extensions of this picklist are unknown.', 'engine.translationPreview.empty': 'This bundle is empty — add at least one translated string to see the coverage report.', 'engine.translationPreview.category.objects': 'Objects', @@ -2488,6 +2506,11 @@ const ENGINE_STRINGS_EN: Record = { 'designer.field.noGroup': '— No group —', 'designer.field.picklistValues': 'Picklist values', 'designer.field.noValues': 'No values yet.', + // objectui#10202 — "use picklist": the field names a shared picklist instead of its own options. + 'designer.field.optionSource': 'Options from', + 'designer.field.optionSourceInline': "This field's own options", + 'designer.field.picklistBound': + 'Options come from the picklist “{picklist}”. A field bound to a picklist has no options of its own: to offer another value, add it to the picklist in its package.', 'designer.field.optMalformed': 'This option cannot be edited here', 'designer.field.optMalformed.notAnObject': 'It is not an option object.', 'designer.field.optMalformed.valueNotText': 'Its `value` is missing or is not text.', @@ -4604,6 +4627,20 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.positionPreview.bind': '将 {name} 绑定到一个或多个 {permissionSets},以控制 CRUD-VAMA、字段访问和标签页可见性。记录的可见性来自业务单元树和共享规则,而不是岗位。', 'engine.positionPreview.permissionSets': '权限集', + // objectui#10202 — the read-only picklist page (zh). + 'engine.picklistPreview.empty': '该选项列表没有名称。', + 'engine.picklistPreview.readOnly': '只读:选项列表归声明它的软件包所有,不能在这里新建、编辑或删除。', + 'engine.picklistPreview.ownedBy': '所属软件包', + 'engine.picklistPreview.options': '选项', + 'engine.picklistPreview.colLabel': '标签', + 'engine.picklistPreview.colValue': '取值', + 'engine.picklistPreview.noOptions': '该选项列表没有声明任何选项。', + 'engine.picklistPreview.extensions': '扩展', + 'engine.picklistPreview.extensionsHint': '其他软件包为该选项列表追加的选项。', + 'engine.picklistPreview.addedBy': '由 {package} 追加', + 'engine.picklistPreview.noExtensions': '没有软件包扩展该选项列表。', + 'engine.picklistPreview.extensionsLoading': '正在加载扩展…', + 'engine.picklistPreview.extensionsFailed': '无法读取已安装的软件包,因此不知道该选项列表有哪些扩展。', 'engine.translationPreview.empty': '此语言包为空 —— 至少添加一条译文,即可查看覆盖率报告。', 'engine.translationPreview.category.objects': '对象', 'engine.translationPreview.category.apps': '应用', @@ -5389,6 +5426,11 @@ const ENGINE_STRINGS_ZH: Record = { 'designer.field.noGroup': '— 无分组 —', 'designer.field.picklistValues': '选项值', 'designer.field.noValues': '暂无选项值。', + // objectui#10202 — "use picklist" (zh): the field names a shared picklist instead of its own options. + 'designer.field.optionSource': '选项来源', + 'designer.field.optionSourceInline': '本字段自己的选项', + 'designer.field.picklistBound': + '选项来自选项列表「{picklist}」。绑定了选项列表的字段没有自己的选项:要增加取值,请在选项列表所属的软件包里添加。', 'designer.field.optMalformed': '此选项无法在这里编辑', 'designer.field.optMalformed.notAnObject': '它不是一个选项对象。', 'designer.field.optMalformed.valueNotText': '它的 `value` 缺失或不是文本。', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index f5f47a09d9..3a986e084c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -34,7 +34,12 @@ */ import * as React from 'react'; -import { FieldSchema, VALUE_DOMAIN_FIELD_TYPES } from '@objectstack/spec/data'; +import { + FieldSchema, + MULTI_OPTION_TYPES, + SINGLE_OPTION_TYPES, + VALUE_DOMAIN_FIELD_TYPES, +} from '@objectstack/spec/data'; import { ValueDomainSchema } from '@objectstack/spec/shared'; import type { MetadataInspectorProps } from '../inspector-registry.js'; import { MetadataClient } from '@object-ui/data-objectstack'; @@ -70,6 +75,7 @@ import { import { CelPredicateField } from '../CelPredicateField.js'; import type { CelLintIssue } from '../celAuthoring.js'; import { t, tFormat } from '../i18n.js'; +import { usePickerLoad, type LoadState } from '../loadState.js'; /** @@ -266,6 +272,16 @@ function isPicklist(type: string): boolean { return type === 'select' || type === 'multiselect' || type === 'radio' || type === 'checkboxes'; } +/** + * Whether a field of this type may name a shared `picklist` — the spec's own + * option types (`SINGLE_OPTION_TYPES` ∪ `MULTI_OPTION_TYPES`), read from the + * installed contract rather than re-listed. `FieldSchema` refuses `picklist` on + * any other type, so a retype away from these drops the binding. + */ +function takesPicklist(type: string): boolean { + return SINGLE_OPTION_TYPES.has(type) || MULTI_OPTION_TYPES.has(type); +} + function isLookup(type: string): boolean { return type === 'lookup' || type === 'master_detail' || type === 'tree'; } @@ -474,6 +490,9 @@ export function ObjectFieldInspector({ : []; const objectOptions = useObjectOptions(locale); + // objectui#10202 — the picklists the runtime serves, for the "use picklist" + // picker. A `LoadState`, so a failed read is never shown as "no picklists". + const picklistRoster = usePicklistRoster(); // Spec `Field.returnType` is stamped from the formula's inferred CEL type, // but ONLY once the author actually edits the formula in this session — @@ -628,6 +647,38 @@ export function ObjectFieldInspector({ writeView({ shape: view.shape, entries: nextEntries }); }; + /** + * objectui#10202 — "use picklist". Naming a list sets `picklist` and removes + * `options` outright (the key is ABSENT, as `unsetDefKey` leaves it): the + * field's options come from exactly one source, and the spec refuses both. + * Choosing the field's own options removes `picklist` AND any `options` the + * field was carrying — those were the list's resolved copy, served onto the + * field, never options the author wrote — so the author starts the inline + * list from the editor below. + */ + const bindPicklist = (picklist: string | undefined) => { + const nextDef = { ...def }; + delete nextDef.options; + if (picklist) nextDef.picklist = picklist; + else delete nextDef.picklist; + const nextEntries = [...view.entries]; + nextEntries[idx] = { ...entry, def: nextDef }; + writeView({ shape: view.shape, entries: nextEntries }); + }; + + /** A retype keeps a picklist binding only on a type the spec lets name one. */ + const changeType = (nextType: string) => { + if (def.picklist === undefined || takesPicklist(nextType)) { + patchDef({ type: nextType }); + return; + } + const nextDef: Record = { ...def, type: nextType }; + delete nextDef.picklist; + const nextEntries = [...view.entries]; + nextEntries[idx] = { ...entry, def: nextDef }; + writeView({ shape: view.shape, entries: nextEntries }); + }; + const setKey = (rawNext: string) => { const nextName = toFieldNameLoose(rawNext); const rejected = @@ -727,6 +778,20 @@ export function ObjectFieldInspector({ const optionRows = readOptions(def); const options = representableOptions(optionRows); + // objectui#10202 — a picklist-bound field offers the LIST's values: the + // served field's resolved copy when it carries one, else the named list's + // own options from the roster (a binding made in this session, or a stored + // draft, carries no `options`). + const boundListed = + typeof def.picklist === 'string' && picklistRoster.status === 'loaded' + ? picklistRoster.data.find((p) => p.name === def.picklist) + : undefined; + const offeredOptions = + def.picklist === undefined || options.length > 0 + ? options + : boundListed + ? representableOptions(readOptions({ options: boundListed.options })) + : []; const patchOptions = (next: OptionRow[]) => { const clean = next.map((row) => { // An entry this editor refuses to represent is written back EXACTLY as it @@ -865,7 +930,7 @@ export function ObjectFieldInspector({ label={tr('designer.field.type')} value={type} options={typeOptions} - onCommit={(v) => patchDef({ type: v })} + onCommit={changeType} disabled={readOnly} />
@@ -893,7 +958,7 @@ export function ObjectFieldInspector({ patchDef({ defaultValue: v })} disabled={readOnly} locale={locale} @@ -913,6 +978,22 @@ export function ObjectFieldInspector({ {(isPicklist(type) || isLookup(type) || isComputed(type) || isNumeric(type) || isTexty(type) || offersValueDomain(type)) && (
{isPicklist(type) && ( + + )} + {isPicklist(type) && def.picklist !== undefined && ( + + )} + {isPicklist(type) && def.picklist === undefined && ( { + const client: MetadataClient = useMetadataClient(); + const load = React.useMemo( + () => async (): Promise => { + const items = await client.list>('picklist'); + return (items ?? []) + .map((raw) => + raw && typeof raw === 'object' && 'item' in raw + ? ((raw as { item?: Record }).item ?? {}) + : (raw as Record), + ) + .filter((i): i is Record & { name: string } => typeof i?.name === 'string' && i.name !== '') + .map((i) => ({ + name: i.name, + label: typeof i.label === 'string' && i.label ? i.label : i.name, + options: Array.isArray(i.options) ? (i.options as unknown[]) : [], + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, + [client], + ); + return usePickerLoad(load); +} + +/** + * Where a choice field's options come from: its own inline list, or a served + * picklist named here. A closed list — the field can only name a picklist the + * runtime serves, so a misspelled name cannot be written; a stored name the + * served list no longer carries is drawn flagged by {@link InspectorSelectField}. + */ +function PicklistSourceField({ + boundPicklist, + roster, + onCommit, + disabled, + locale, +}: { + boundPicklist: string | undefined; + roster: LoadState; + onCommit: (picklist: string | undefined) => void; + disabled?: boolean; + locale?: string; +}) { + const served = roster.status === 'loaded' ? roster.data : []; + const options = [ + { value: '', label: t('designer.field.optionSourceInline', locale) }, + ...served.map((p) => ({ value: p.name, label: p.label === p.name ? p.name : `${p.label} (${p.name})` })), + ]; + return ( + onCommit(v === '' ? undefined : v)} + disabled={disabled} + /> + ); +} + +/** + * A picklist-bound field's options, read-only, in place of the inline options + * editor: inline options are refused on a bound field, as the spec refuses + * `picklist` with `options`. The options shown are the ones the field offers + * (see `offeredOptions` in the inspector); nothing here writes. + */ +function BoundPicklistOptions({ + picklistLabel, + options, + locale, +}: { + picklistLabel: string; + options: Option[]; + locale?: string; +}) { + return ( +
+ +

+ {tFormat('designer.field.picklistBound', locale, { picklist: picklistLabel })} +

+ {options.length > 0 && ( +
    + {options.map((o) => ( +
  • + {o.label || o.value} + {o.value} +
  • + ))} +
+ )} +
+ ); +} + /* ─────────────── Hook: load object list for lookup picker ─────────────── */ function useObjectOptions(locale?: string): Array<{ value: string; label: string }> { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx new file mode 100644 index 0000000000..881157a3e0 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10202 — "use picklist" in the select-field editor. + * + * The ruling (6006062645, item (a)): a picker over the picklists the runtime + * serves, by name; choosing one sets `picklist` and clears inline `options`; + * inline options are refused on a picklist-bound field, as the spec refuses + * both. + * + * The inspector is the real one, mounted in a host that applies each `onPatch` + * the way the designers do (`{ ...draft, ...patch }`), so every assertion reads + * the field the next save would carry. The roster is the inspector's own read, + * `client.list('picklist')`, answered with the row shape the runtime serves + * (measured on objectstack `main`: `name`, `label`, `options`, `_packageId`). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FieldSchema } from '@objectstack/spec/data'; + +type Row = Record; + +/** `GET /meta/picklist`, as the runtime serves it. */ +const SERVED_PICKLISTS: Row[] = [ + { + name: 'acme_tier', + label: 'Tier', + options: [ + { label: 'Gold', value: 'gold' }, + { label: 'Silver', value: 'silver' }, + ], + _packageId: 'com.acme.core', + }, + { name: 'acme_size', label: 'Size', options: [{ label: 'Small', value: 's' }], _packageId: 'com.acme.core' }, +]; + +const roster = vi.hoisted(() => ({ + answer: null as null | (() => Promise), +})); + +vi.mock('../useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + const client = { + list: async (type: string) => (type === 'picklist' && roster.answer ? roster.answer() : []), + listDrafts: async () => [], + }; + return { ...mod, useMetadataClient: () => client }; +}); + +vi.mock('../previews/useObjectFields', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useObjectFields: () => ({ fields: [], loading: false, error: null }) }; +}); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; + +beforeEach(() => { + roster.answer = async () => SERVED_PICKLISTS.map((p) => ({ ...p })); + // Radix Select reads pointer capture, which the DOM double does not implement. + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!(m in Element.prototype)) { + (Element.prototype as unknown as Record)[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } + if (!('scrollIntoView' in Element.prototype)) { + (Element.prototype as unknown as Record).scrollIntoView = () => {}; + } +}); + +afterEach(cleanup); + +/** The inspector in a host that applies its patches, as both designers do. */ +function mount(fields: Record, selected: string) { + let latest: Row = { name: 'acme_account', fields }; + function Host() { + const [draft, setDraft] = React.useState(latest); + return ( + + setDraft((d) => { + latest = { ...d, ...patch }; + return latest; + }) + } + onClearSelection={() => {}} + onSelectionChange={() => {}} + readOnly={false} + locale="en-US" + /> + ); + } + render(); + return { field: () => (latest.fields as Record)[selected] }; +} + +const sourcePicker = () => screen.getByRole('combobox', { name: 'Options from' }); + +async function choose(optionName: string | RegExp) { + await userEvent.click(sourcePicker()); + await userEvent.click(await screen.findByRole('option', { name: optionName })); +} + +/** The inline options editor's own affordance: present exactly when inline options are offered. */ +const addValueButton = () => screen.queryByRole('button', { name: 'Add value' }); + +describe('ObjectFieldInspector — use picklist (objectui#10202)', () => { + it('offers the served picklists by name, beside the field\'s own options', async () => { + mount({ status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] } }, 'status'); + await userEvent.click(sourcePicker()); + expect(await screen.findByRole('option', { name: 'Tier (acme_tier)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Size (acme_size)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: "This field's own options" })).toBeInTheDocument(); + }); + + it('choosing a picklist sets `picklist`, removes `options` outright, and the field is one the door accepts', async () => { + const { field } = mount( + { status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] } }, + 'status', + ); + expect(addValueButton(), 'premise: an inline field offers its options editor').not.toBeNull(); + await choose('Tier (acme_tier)'); + + expect(field()).toEqual({ type: 'select', label: 'Status', picklist: 'acme_tier' }); + expect(Object.prototype.hasOwnProperty.call(field(), 'options')).toBe(false); + const parsed = FieldSchema.safeParse(field()); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('a bound field refuses inline options: no options editor, the list\'s values shown read-only with the reason', async () => { + mount({ tier: { type: 'select', label: 'Tier', picklist: 'acme_tier' } }, 'tier'); + await waitFor(() => expect(screen.getByTestId('picklist-bound-options')).toHaveTextContent('Gold')); + const bound = screen.getByTestId('picklist-bound-options'); + expect(bound).toHaveTextContent('Silver'); + expect(bound).toHaveTextContent(/Options come from the picklist “Tier”/); + expect(addValueButton(), 'no inline options editor on a bound field').toBeNull(); + expect(bound.querySelector('input, textarea, button')).toBeNull(); + }); + + it('a SERVED bound field shows the resolved options it carries (extensions included)', async () => { + mount( + { + tier: { + type: 'select', + label: 'Tier', + picklist: 'acme_tier', + options: [ + { label: 'Gold', value: 'gold' }, + { label: 'Silver', value: 'silver' }, + { label: 'Platinum', value: 'platinum' }, + ], + }, + }, + 'tier', + ); + expect(screen.getByTestId('picklist-bound-options')).toHaveTextContent('Platinum'); + expect(addValueButton()).toBeNull(); + }); + + it('choosing the field\'s own options removes `picklist` and the served copy, and offers the editor again', async () => { + const { field } = mount( + { tier: { type: 'select', label: 'Tier', picklist: 'acme_tier', options: [{ label: 'Gold', value: 'gold' }] } }, + 'tier', + ); + await choose("This field's own options"); + expect(field()).toEqual({ type: 'select', label: 'Tier' }); + expect(addValueButton()).not.toBeNull(); + expect(screen.queryByTestId('picklist-bound-options')).toBeNull(); + }); + + it('a retype keeps the binding on another option type and drops it on any other type (the spec refuses it there)', async () => { + const { field } = mount({ tier: { type: 'select', label: 'Tier', picklist: 'acme_tier' } }, 'tier'); + await userEvent.click(screen.getByRole('combobox', { name: 'Type' })); + await userEvent.click(await screen.findByRole('option', { name: 'Selection · Multi-Select' })); + expect(field()).toMatchObject({ type: 'multiselect', picklist: 'acme_tier' }); + + await userEvent.click(screen.getByRole('combobox', { name: 'Type' })); + await userEvent.click(await screen.findByRole('option', { name: 'Text · Text' })); + expect(field()).toEqual({ type: 'text', label: 'Tier' }); + expect(FieldSchema.safeParse(field()).success).toBe(true); + }); + + it('a bound field\'s default value is chosen from the list\'s values', async () => { + const { field } = mount({ tier: { type: 'select', label: 'Tier', picklist: 'acme_tier' } }, 'tier'); + await waitFor(() => expect(screen.getByTestId('picklist-bound-options')).toHaveTextContent('Gold')); + await userEvent.click(screen.getByRole('combobox', { name: 'Default value' })); + await userEvent.click(await screen.findByRole('option', { name: 'Silver' })); + expect(field()).toMatchObject({ picklist: 'acme_tier', defaultValue: 'silver' }); + }); + + it('a stored name the served list does not carry is drawn flagged, never as unset', async () => { + mount({ tier: { type: 'select', label: 'Tier', picklist: 'acme_gone' } }, 'tier'); + await waitFor(() => expect(sourcePicker()).toHaveTextContent('acme_gone (not found)')); + }); + + it('a failed roster read says so and keeps the field\'s own options choosable; it is never drawn as "no picklists"', async () => { + roster.answer = async () => { + throw new Error('HTTP 503'); + }; + const { field } = mount({ tier: { type: 'select', label: 'Tier', picklist: 'acme_tier' } }, 'tier'); + const notice = await screen.findByTestId('inspector-select-roster-failure'); + expect(notice).toHaveTextContent('HTTP 503'); + // The stored binding is drawn bare (no "not found" claim from a roster that never answered). + expect(sourcePicker()).toHaveTextContent('acme_tier'); + expect(sourcePicker()).not.toHaveTextContent('not found'); + await act(async () => { + await choose("This field's own options"); + }); + expect(field()).toEqual({ type: 'select', label: 'Tier' }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/picklist-binding.test.ts b/packages/app-shell/src/views/metadata-admin/picklist-binding.test.ts new file mode 100644 index 0000000000..6689eea985 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/picklist-binding.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10202 — `dropServedPicklistOptions`, the served → authored + * conversion an object designer applies to the body it PUTs. + * + * The contract it answers to is read from the installed `@objectstack/spec`, + * not restated: `FieldSchema` refuses `picklist` beside `options` (the served + * form), accepts `picklist` alone (the authored form), and `ObjectSchema` + * refuses a whole document that carries one served field. So the pins below say + * what the helper does AND that what it produces is what the door accepts. + */ + +import { describe, expect, it } from 'vitest'; +import { FieldSchema, ObjectSchema } from '@objectstack/spec/data'; +import { dropServedPicklistOptions } from './picklist-binding'; + +type Row = Record; + +/** The options the runtime resolved onto a bound field: the list's own plus an extension's. */ +const RESOLVED = [ + { label: 'Gold', value: 'gold', color: '#d4af37' }, + { label: 'Silver', value: 'silver' }, + { label: 'Platinum', value: 'platinum' }, +]; + +/** A served object: two bound fields (single and multi), an inline select, a text field. */ +function servedObject(): Row { + return { + name: 'acme_account', + label: 'Account', + fields: { + tier: { type: 'select', label: 'Tier', picklist: 'acme_tier', options: RESOLVED.map((o) => ({ ...o })) }, + regions: { type: 'multiselect', label: 'Regions', picklist: 'acme_region', options: [{ label: 'EU', value: 'eu' }] }, + status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }] }, + title: { type: 'text', label: 'Title', maxLength: 200 }, + }, + }; +} + +describe('dropServedPicklistOptions (objectui#10202)', () => { + it('premise, read off the installed spec: a served field is refused at `options`, the authored form is accepted', () => { + const served = FieldSchema.safeParse({ type: 'select', label: 'Tier', picklist: 'acme_tier', options: RESOLVED }); + expect(served.success).toBe(false); + expect(served.error?.issues.map((i) => i.path.join('.'))).toContain('options'); + expect(FieldSchema.safeParse({ type: 'select', label: 'Tier', picklist: 'acme_tier' }).success).toBe(true); + // And the whole document is refused while one served field rides in it. + expect(ObjectSchema.safeParse(servedObject()).success).toBe(false); + }); + + it('drops `options` from EVERY picklist-bound field, and the result is a document the door accepts', () => { + const body = dropServedPicklistOptions(servedObject()); + const fields = body.fields as Record; + expect(fields.tier).toEqual({ type: 'select', label: 'Tier', picklist: 'acme_tier' }); + expect(fields.regions).toEqual({ type: 'multiselect', label: 'Regions', picklist: 'acme_region' }); + expect(fields.tier).not.toHaveProperty('options'); + expect(fields.regions).not.toHaveProperty('options'); + const parsed = ObjectSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('strips nothing else: a field without `picklist` keeps its inline options byte-identical, and so does every other key', () => { + const input = servedObject(); + const before = JSON.stringify(input); + const body = dropServedPicklistOptions(input); + const inFields = input.fields as Record; + const outFields = body.fields as Record; + // Untouched fields are the SAME objects, not copies. + expect(outFields.status).toBe(inFields.status); + expect(outFields.title).toBe(inFields.title); + expect(JSON.stringify(outFields.status)).toBe(JSON.stringify(inFields.status)); + // Document keys other than `fields` ride along unchanged, in order. + expect(Object.keys(body)).toEqual(Object.keys(input)); + expect(body.name).toBe('acme_account'); + expect(body.label).toBe('Account'); + // Field order is preserved (declaration order IS the designer's order). + expect(Object.keys(outFields)).toEqual(['tier', 'regions', 'status', 'title']); + // The input is never mutated. + expect(JSON.stringify(input)).toBe(before); + }); + + it('handles the ARRAY `fields` shape a document can arrive in', () => { + const input: Row = { + name: 'acme_account', + fields: [ + { name: 'tier', type: 'select', picklist: 'acme_tier', options: RESOLVED }, + { name: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }] }, + ], + }; + const body = dropServedPicklistOptions(input); + const out = body.fields as Row[]; + expect(out[0]).toEqual({ name: 'tier', type: 'select', picklist: 'acme_tier' }); + expect(out[1]).toBe((input.fields as Row[])[1]); + }); + + it('comes back BY REFERENCE when no bound field carries options', () => { + const authored: Row = { + name: 'acme_account', + fields: { tier: { type: 'select', picklist: 'acme_tier' }, status: { type: 'select', options: [{ label: 'Open', value: 'open' }] } }, + }; + expect(dropServedPicklistOptions(authored)).toBe(authored); + const noFields: Row = { name: 'acme_account' }; + expect(dropServedPicklistOptions(noFields)).toBe(noFields); + const asArray: Row = { name: 'x', fields: [{ name: 'status', type: 'select', options: [{ label: 'A', value: 'a' }] }] }; + expect(dropServedPicklistOptions(asArray)).toBe(asArray); + }); + + it('keeps a field literally named `__proto__` as an own key', () => { + const fields = Object.fromEntries([ + ['__proto__', { type: 'select', picklist: 'acme_tier', options: RESOLVED }], + ]); + const body = dropServedPicklistOptions({ name: 'x', fields }); + const out = body.fields as Record; + expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true); + expect(Object.getOwnPropertyDescriptor(out, '__proto__')?.value).toEqual({ type: 'select', picklist: 'acme_tier' }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/picklist-binding.ts b/packages/app-shell/src/views/metadata-admin/picklist-binding.ts new file mode 100644 index 0000000000..6f6b555104 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/picklist-binding.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The served form of a picklist-bound field, turned back into the authored + * form before an object designer writes it (objectui#10202). + * + * ## Why the designer has to do this + * + * A select field may name a shared list instead of carrying its own options: + * `picklist: 'industry'` (`FieldSchema.picklist`, `@objectstack/spec` + * `data/picklist.zod.ts`). The runtime serves such a field with BOTH keys: + * `picklist`, still naming the list, and `options`, RESOLVED from that list and + * from every `picklistExtensions` entry other packages add to it + * (`PicklistServedFieldSchema`). Every object read the designers seed from + * carries that served form — `GET /meta/object/NAME` and the `effective` layer + * of `GET /meta/object/NAME/layers` alike. + * + * The authoring door refuses the two keys together: `FieldSchema` adds an issue + * at `fields.FIELD.options` whose prescription is "Keep `picklist` and delete + * `options`". So a designer that writes its seed back unchanged gets + * `422 INVALID_METADATA` for the whole object, whatever the author edited. + * The resolved list belongs to the picklist, not to the field: the producer of + * the PUT drops it (objectstack's producer obligation, pinned server-side in + * `protocol-picklist-served-roundtrip.test.ts`). + * + * ## What this changes, and what it never touches + * + * EVERY field that names a picklist leaves without `options` — not only the one + * the author edited, because the whole document is re-sent and any bound field + * in it is refused. A field that names no picklist is not touched at all: its + * inline `options` stay byte-identical. No other key of any field, and no key of + * the document, is touched. + * + * "Names a picklist" is the spec's own test, `picklist !== undefined` (the + * `FieldSchema` refinement), so this drops `options` on exactly the fields the + * door would refuse for carrying both. + * + * Both `fields` shapes a document can carry are handled: the spec's record keyed + * by field name, and the array `readFields` / `writeFields` round-trip for a + * document that arrived that way. + * + * ⛔ Not at the transport. `MetadataClient.save` and the other metadata-write + * doors cannot tell a served copy from an author who wrote both keys, and the + * second must stay a loud refusal with its prescription. The designer can tell: + * it seeded its draft from the served read. + * + * Pure: the input is never mutated, and a document with nothing to drop comes + * back BY REFERENCE, so a caller can tell "unchanged" by identity. + */ +export function dropServedPicklistOptions>(body: T): T { + if (!body || typeof body !== 'object') return body; + const fields = body.fields; + if (Array.isArray(fields)) { + let changed = false; + const next = fields.map((def) => { + const stripped = withoutServedOptions(def); + if (stripped !== def) changed = true; + return stripped; + }); + return changed ? { ...body, fields: next } : body; + } + if (fields && typeof fields === 'object') { + let changed = false; + // `Object.fromEntries` keeps a field literally named `__proto__` as an own + // key, where assignment into a literal would invoke the prototype setter + // and drop it (the hazard `MetadataService.toFieldsMap` documents). + const next = Object.fromEntries( + Object.entries(fields as Record).map(([name, def]) => { + const stripped = withoutServedOptions(def); + if (stripped !== def) changed = true; + return [name, stripped]; + }), + ); + return changed ? { ...body, fields: next } : body; + } + return body; +} + +/** One field without its served `options` — the same reference when there is nothing to drop. */ +function withoutServedOptions(def: unknown): unknown { + if (!def || typeof def !== 'object' || Array.isArray(def)) return def; + const record = def as Record; + if (record.picklist === undefined) return def; + if (!Object.prototype.hasOwnProperty.call(record, 'options')) return def; + const { options: _served, ...authored } = record; + return authored; +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx new file mode 100644 index 0000000000..ec4d187e66 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10202 — the read-only picklist page. + * + * Ruling 6006062645 item (c): name, label, options with labels, extensions with + * their owning package; no create, edit or delete. The inputs below are the + * shapes the runtime serves, measured on objectstack `main`: + * + * - `GET /meta/picklist/NAME` → the owning list: `name`, `label`, + * `description`, its own `options`, `_packageId` — and NOT the extensions; + * - `GET /meta/package` → one row per installed package whose `manifest` + * carries its `picklistExtensions` as authored (`{ extend, options }`). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; + +type Row = Record; + +/** The served list, as `GET /meta/picklist/showcase_priority_tier` answered. */ +const SERVED_LIST: Row = { + name: 'showcase_priority_tier', + label: 'Priority Tier', + description: 'Shared tier list', + options: [ + { label: 'Gold', value: 'gold', color: '#d4af37' }, + { label: 'Silver', value: 'silver' }, + { label: 'Bronze', value: 'bronze' }, + ], + _packageId: 'com.example.showcase', + _packageVersion: '0.1.0', + _provenance: 'package', +}; + +/** Package rows, in the shape `GET /meta/package` serves (reduced to the keys read here). */ +const PACKAGES: Row[] = [ + { + enabled: true, + status: 'installed', + manifest: { + id: 'com.example.showcase', + name: 'ObjectStack Showcase', + picklists: [{ name: 'showcase_priority_tier' }], + picklistExtensions: [], + }, + }, + { + enabled: true, + status: 'installed', + manifest: { + id: 'com.acme.addon', + name: 'Acme Add-on', + picklistExtensions: [ + { extend: 'showcase_priority_tier', options: [{ label: 'Platinum', value: 'platinum' }] }, + { extend: 'some_other_list', options: [{ label: 'Elsewhere', value: 'elsewhere' }] }, + ], + }, + }, + { enabled: true, status: 'installed', manifest: { id: 'com.objectstack.setup', name: 'Setup' } }, +]; + +const packages = vi.hoisted(() => ({ answer: null as null | (() => Promise) })); + +vi.mock('../useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + const client = { list: async (type: string) => (type === 'package' && packages.answer ? packages.answer() : []) }; + return { ...mod, useMetadataClient: () => client }; +}); + +import { PicklistPreview, extensionsOf } from './PicklistPreview'; +import { registerBuiltinPreviews } from './index'; +import { getMetadataPreview } from '../preview-registry'; + +beforeEach(() => { + packages.answer = async () => PACKAGES.map((p) => JSON.parse(JSON.stringify(p)) as Row); +}); +afterEach(cleanup); + +function renderPreview(draft: Row = SERVED_LIST) { + return render(); +} + +describe('PicklistPreview — the read-only picklist page (objectui#10202)', () => { + it('is the registered preview for the `picklist` kind', () => { + registerBuiltinPreviews(); + expect(getMetadataPreview('picklist')).toBe(PicklistPreview); + }); + + it('shows the name, label, description, owning package and each option with its label', async () => { + renderPreview(); + expect(screen.getByText('Priority Tier')).toBeInTheDocument(); + expect(screen.getByText('showcase_priority_tier')).toBeInTheDocument(); + expect(screen.getByText('Shared tier list')).toBeInTheDocument(); + expect(screen.getByText('com.example.showcase')).toBeInTheDocument(); + const options = screen.getByRole('region', { name: 'Options' }); + for (const [label, value] of [['Gold', 'gold'], ['Silver', 'silver'], ['Bronze', 'bronze']]) { + const row = within(options).getByText(label).closest('tr')!; + expect(row).toHaveTextContent(value); + } + await screen.findAllByTestId('picklist-extension'); + }); + + it('shows each extension under the package that declares it, and only those that extend THIS list', async () => { + renderPreview(); + const ext = await screen.findAllByTestId('picklist-extension'); + expect(ext).toHaveLength(1); + expect(ext[0]).toHaveTextContent('Added by Acme Add-on (com.acme.addon)'); + expect(ext[0]).toHaveTextContent('Platinum'); + expect(ext[0]).toHaveTextContent('platinum'); + expect(screen.queryByText('Elsewhere')).toBeNull(); + }); + + it('offers no create, edit or delete: no button, input or link anywhere on the page', async () => { + const { container } = renderPreview(); + await screen.findAllByTestId('picklist-extension'); + expect(container.querySelectorAll('button, input, textarea, select, a, [role="button"]')).toHaveLength(0); + expect(screen.getByText(/Read-only: a picklist is owned by the package that declares it/)).toBeInTheDocument(); + }); + + it('says so when no package extends the list', async () => { + packages.answer = async () => [PACKAGES[0], PACKAGES[2]]; + renderPreview(); + expect(await screen.findByText('No package extends this picklist.')).toBeInTheDocument(); + }); + + it('a failed package read says the extensions are unknown — never that there are none', async () => { + packages.answer = async () => { + throw new Error('HTTP 503'); + }; + renderPreview(); + const notice = await screen.findByRole('status'); + expect(notice).toHaveTextContent('the extensions of this picklist are unknown'); + expect(notice).toHaveTextContent('HTTP 503'); + expect(screen.queryByText('No package extends this picklist.')).toBeNull(); + }); + + it('extensionsOf reads the wrapped `{ item }` row shape too, and ignores rows without a manifest id', () => { + const rows = extensionsOf('showcase_priority_tier', [ + { item: PACKAGES[1] }, + { manifest: { picklistExtensions: [{ extend: 'showcase_priority_tier', options: [{ label: 'X', value: 'x' }] }] } }, + null, + 'junk', + ]); + expect(rows).toEqual([ + { packageId: 'com.acme.addon', packageName: 'Acme Add-on', options: [{ label: 'Platinum', value: 'platinum' }] }, + ]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx new file mode 100644 index 0000000000..e87da9bd12 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PicklistPreview — the read-only page of a shared picklist (objectui#10202). + * + * A picklist is one list of select options that fields on several objects name + * with `picklist: 'NAME'` instead of carrying their own (`PicklistSchema`, + * `@objectstack/spec` `data/picklist.zod.ts`). The kind is PACKAGE-OWNED: its + * registry entry declares `allowRuntimeCreate: false` and `allowOrgOverride: + * false`, so the generic engine already offers no create and renders the item + * read-only. This preview is the page's detail view and it is read-only too: + * it offers no create, edit or delete of any kind, because the server refuses + * all three for this kind. + * + * It shows what the protocol declares, and nothing it does not: + * + * • the list's `label`, `name`, `description` and its owning package + * (`_packageId`, the loader's protection stamp on every registered item); + * • its `options`, each with its label and value; + * • its EXTENSIONS: the options other packages add with + * `picklistExtensions: [{ extend: NAME, options }]`, each under the package + * that declares it. + * + * ## Where the extensions come from + * + * `GET /meta/picklist/NAME` serves the owning list only: the runtime merges the + * extensions into each bound FIELD's resolved `options`, not into the list's + * own read. The declaring package of each extension is served on the package + * read instead — every installed package's manifest carries its + * `picklistExtensions` as authored (`PicklistExtensionSchema`: `extend` + + * `options`) — so this page reads `GET /meta/package` and keeps the entries + * whose `extend` names this list. That read is a {@link LoadState}: a failed + * read says the extensions are unknown, never that there are none. + */ + +import * as React from 'react'; +import { ListOrdered, Lock, Package } from 'lucide-react'; +import type { MetadataPreviewProps } from '../preview-registry.js'; +import { t as tr, tFormat } from '../i18n.js'; +import { useMetadataClient } from '../useMetadata.js'; +import { usePickerLoad, type LoadState } from '../loadState.js'; +import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell.js'; + +/** One option as this page shows it: what the field offers, by label and value. */ +interface ShownOption { + value: string; + label: string; +} + +/** One `picklistExtensions` entry that names this list, with its declaring package. */ +export interface PicklistExtensionRow { + packageId: string; + packageName?: string; + options: ShownOption[]; +} + +/** The option shape a list or an extension declares, read defensively (the draft is untrusted). */ +function shownOptions(raw: unknown): ShownOption[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((o) => { + if (!o || typeof o !== 'object') return []; + const option = o as { value?: unknown; label?: unknown }; + if (option.value === undefined || option.value === null) return []; + const value = String(option.value); + return [{ value, label: typeof option.label === 'string' && option.label ? option.label : value }]; + }); +} + +/** + * The `picklistExtensions` entries naming `picklist`, across every installed + * package's manifest as `GET /meta/package` serves it. + * + * Exported for its pin: the row shape the package read serves is the input this + * page depends on, so the pin feeds it the measured shape. + */ +export function extensionsOf(picklist: string, packages: readonly unknown[]): PicklistExtensionRow[] { + const rows: PicklistExtensionRow[] = []; + for (const raw of packages) { + const row = + raw && typeof raw === 'object' && 'item' in raw + ? (raw as { item?: unknown }).item + : raw; + if (!row || typeof row !== 'object') continue; + const manifest = ((row as { manifest?: unknown }).manifest ?? row) as Record; + const packageId = typeof manifest.id === 'string' ? manifest.id : undefined; + if (!packageId || !Array.isArray(manifest.picklistExtensions)) continue; + for (const ext of manifest.picklistExtensions) { + if (!ext || typeof ext !== 'object') continue; + const entry = ext as { extend?: unknown; options?: unknown }; + if (entry.extend !== picklist) continue; + rows.push({ + packageId, + packageName: typeof manifest.name === 'string' && manifest.name ? manifest.name : undefined, + options: shownOptions(entry.options), + }); + } + } + return rows; +} + +function usePicklistExtensions(picklist: string): LoadState { + const client = useMetadataClient(); + const load = React.useMemo( + () => (picklist ? async () => extensionsOf(picklist, (await client.list('package')) ?? []) : null), + [client, picklist], + ); + return usePickerLoad(load); +} + +function OptionTable({ options, locale }: { options: ShownOption[]; locale?: string }) { + return ( + + + + + + + + + {options.map((o) => ( + + + + + ))} + +
{tr('engine.picklistPreview.colLabel', locale)}{tr('engine.picklistPreview.colValue', locale)}
{o.label}{o.value}
+ ); +} + +function ExtensionsSection({ picklist, locale }: { picklist: string; locale?: string }) { + const state = usePicklistExtensions(picklist); + return ( +
+
+
{tr('engine.picklistPreview.extensions', locale)}
+
{tr('engine.picklistPreview.extensionsHint', locale)}
+
+ {state.status === 'error' ? ( +

+ {tr('engine.picklistPreview.extensionsFailed', locale)}{' '} + {state.message} +

+ ) : state.status !== 'loaded' ? ( +

{tr('engine.picklistPreview.extensionsLoading', locale)}

+ ) : state.data.length === 0 ? ( +

{tr('engine.picklistPreview.noExtensions', locale)}

+ ) : ( + state.data.map((ext, i) => ( +
+
+ + + {tFormat('engine.picklistPreview.addedBy', locale, { + package: ext.packageName ? `${ext.packageName} (${ext.packageId})` : ext.packageId, + })} + +
+ +
+ )) + )} +
+ ); +} + +export function PicklistPreview({ name, draft, locale }: MetadataPreviewProps) { + const d = draft as Record; + const picklistName = String(d.name ?? name ?? ''); + const label = typeof d.label === 'string' && d.label ? d.label : picklistName; + const description = typeof d.description === 'string' ? d.description : ''; + const owner = typeof d._packageId === 'string' ? d._packageId : undefined; + const options = shownOptions(d.options); + + if (!picklistName) { + return ( + + {tr('engine.picklistPreview.empty', locale)} + + ); + } + + return ( + + +
+
+
+ +
+
+ {label} + {picklistName} +
+ {description &&
{description}
} +
+
+ {owner && ( +
+ + {tr('engine.picklistPreview.ownedBy', locale)} + {owner} +
+ )} +
+ + {tr('engine.picklistPreview.readOnly', locale)} +
+
+ +
+
{tr('engine.picklistPreview.options', locale)}
+ {options.length === 0 ? ( +

{tr('engine.picklistPreview.noOptions', locale)}

+ ) : ( +
+ +
+ )} +
+ + +
+
+
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/index.ts b/packages/app-shell/src/views/metadata-admin/previews/index.ts index a68629175e..ca213b6cce 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/index.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/index.ts @@ -31,6 +31,7 @@ import { ValidationPreview } from './ValidationPreview.js'; import { DatasetPreview } from './DatasetPreview.js'; import { BookPreview } from './BookPreview.js'; import { DocPreview } from './DocPreview.js'; +import { PicklistPreview } from './PicklistPreview.js'; export function registerBuiltinPreviews(): void { // UI surfaces @@ -43,6 +44,10 @@ export function registerBuiltinPreviews(): void { // Data registerMetadataPreview('object', ObjectPreview); registerMetadataPreview('datasource', DatasourcePreview); + // objectui#10202 — the shared picklist's read-only page: its options, and the + // options other packages add to it, each under its declaring package. The + // kind is package-owned, so the page offers no create, edit or delete. + registerMetadataPreview('picklist', PicklistPreview); // ADR-0088 retired STANDALONE `validation` items, and objectui#4132 removed // the console door that authored them. This registration is not residue: a // rule lives embedded in `object.validations`, and `EmbeddedItemEditor` looks diff --git a/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx new file mode 100644 index 0000000000..40c86eebb4 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10202 — the Studio data page saves an object whose fields name a + * shared picklist. + * + * The page seeds its buffer from the SERVED object (`layers.effective`), where a + * picklist-bound field carries `picklist` and the options the runtime resolved + * from the list and its extensions. The authoring door refuses the two keys + * together (`422 INVALID_METADATA` at `fields.FIELD.options`, measured on + * objectstack `main`), so both of the page's object PUTs that re-send that + * buffer — the draft autosave and the grid's column reorder — leave the served + * `options` out of every bound field, and touch nothing else. + * + * The client is a real `MetadataClient`; only its transport is a double, and + * that double refuses a body the way the authoring door does. So the claim is + * read off the WIRE. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +type Row = Record; + +const PKG = 'com.example.showcase'; + +/** The object as the runtime SERVES it (the record `fields` shape the runtime serves). */ +const SERVED: Row = { + name: 'showcase_account', + label: 'Account', + fields: { + title: { type: 'text', label: 'Title' }, + tier: { + type: 'select', + label: 'Tier', + picklist: 'showcase_tier', + options: [ + { label: 'Gold', value: 'gold' }, + { label: 'Platinum', value: 'platinum' }, + ], + }, + status: { + type: 'select', + label: 'Status', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ], + }, + }, +}; + +/** The authoring door's refusal for a field carrying both keys. */ +function doorRefusal(body: Row): string[] { + const fields = body.fields; + const entries: Array<[string, Row]> = Array.isArray(fields) + ? (fields as Row[]).map((f) => [String(f.name), f]) + : Object.entries((fields ?? {}) as Record); + return entries + .filter(([, def]) => def && def.picklist !== undefined && def.options !== undefined) + .map(([name]) => `fields.${name}.options`); +} + +const puts: Array<{ body: Row; status: number }> = []; + +const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Row; + const refused = doorRefusal(body); + const status = refused.length > 0 ? 422 : 200; + puts.push({ body, status }); + return new Response( + JSON.stringify( + status === 422 + ? { + error: 'failed spec validation', + code: 'INVALID_METADATA', + issues: refused.map((path) => ({ path, code: 'custom', message: '`picklist` and `options` cannot both be declared' })), + } + : { success: true }, + ), + { status, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }); +}); + +const client = new MetadataClient({ baseUrl: 'http://test.local', fetch: fetchImpl as unknown as typeof fetch }); +Object.assign(client, { + list: vi.fn(async (type: string) => (type === 'object' ? [{ name: 'showcase_account', label: 'Account' }] : [])), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: SERVED, code: SERVED })), + getDraft: vi.fn(async () => null), +}); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => client, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +// The records grid: a double that reorders the columns it was handed through +// the grid's authoring context, the way a header drag does. +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const mod = await importOriginal>(); + const { useGridFieldAuthoring } = await import('@object-ui/components'); + function GridDouble({ schema }: { schema?: { table?: { fields?: string[] } } }) { + const authoring = useGridFieldAuthoring(); + const cols = schema?.table?.fields ?? []; + return ( + + ); + } + return { ...mod, ObjectView: GridDouble }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); + +import { DataPillar } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; + +const dataSource = createEmptyDataSource(); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +afterEach(() => { + cleanup(); + puts.length = 0; + fetchImpl.mockClear(); +}); + +function renderData() { + render( + + + , + ); +} + +const fieldCountShown = () => screen.getByText(/^\d+ fields$/).textContent; + +/** Past the autosave's 1.5 s debounce. */ +async function outlastDebounce(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); +} + +/** What every PUT the page sent carried for `tier` and `status`. */ +function sentFields(): Array<{ tier: unknown; status: unknown; status_code: number }> { + return puts.map((p) => { + const f = p.body.fields as Record; + return { tier: f.tier, status: f.status, status_code: p.status }; + }); +} + +const SERVED_STATUS = JSON.stringify((SERVED.fields as Record).status); + +describe('Studio data page — a picklist-bound field is saved without its served options (objectui#10202)', () => { + it('the draft autosave (an added field) sends no bound field with `options`, and the inline select byte-identical', async () => { + renderData(); + await waitFor(() => expect(fieldCountShown()).toBe('3 fields'), { timeout: 8000 }); + fireEvent.click(screen.getByTitle(/^Add a field/)); + await outlastDebounce(); + await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 4000 }); + + for (const sent of sentFields()) { + expect(sent.status_code, 'the door accepted the body').toBe(200); + expect(sent.tier).toEqual({ type: 'select', label: 'Tier', picklist: 'showcase_tier' }); + expect(JSON.stringify(sent.status)).toBe(SERVED_STATUS); + } + }); + + it("the grid's column reorder sends no bound field with `options` either", async () => { + renderData(); + await waitFor(() => expect(fieldCountShown()).toBe('3 fields'), { timeout: 8000 }); + fireEvent.click(await screen.findByRole('button', { name: 'Reverse columns' }, { timeout: 8000 })); + await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 4000 }); + + const first = puts[0]; + expect(first.status, `refused at ${doorRefusal(first.body).join(', ')}`).toBe(200); + expect(Object.keys(first.body.fields as Row)).toEqual(['status', 'tier', 'title']); + const f = first.body.fields as Record; + expect(f.tier).toEqual({ type: 'select', label: 'Tier', picklist: 'showcase_tier' }); + expect(JSON.stringify(f.status)).toBe(SERVED_STATUS); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 0bcaa48d54..63e8a2d954 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -96,6 +96,7 @@ import { } from '../metadata-admin/inspector-registry.js'; import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js'; import { getMetadataResource } from '../metadata-admin/registry.js'; +import { dropServedPicklistOptions } from '../metadata-admin/picklist-binding.js'; import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata.js'; import { DESIGNER_SURFACE_PARAM, @@ -3721,7 +3722,10 @@ export function DataPillar({ setSaving('draft'); setError(null); try { - await client.save('object', current.name, objDraft, { mode: 'draft', packageId }); + // objectui#10202 — the buffer was seeded from the served object, whose + // picklist-bound fields carry the list's resolved `options`; the door + // refuses them beside `picklist`, so they stay out of the body. + await client.save('object', current.name, dropServedPicklistOptions(objDraft), { mode: 'draft', packageId }); setHasDraft(true); // objectui#11204 — clean only if nothing was edited while it was in flight. if (sent.unmoved()) setDirty(false); @@ -3788,7 +3792,8 @@ export function DataPillar({ setSaving('draft'); setError(null); try { - await client.save('object', current.name, body, { mode: 'draft', packageId }); + // objectui#10202 — same served `options` as `doSave` above. + await client.save('object', current.name, dropServedPicklistOptions(body), { mode: 'draft', packageId }); setHasDraft(true); if (sent.unmoved()) setDirty(false); onDraftSaved?.(); From 1d8c6c223a208e22564459fe31c260d5a11068a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:38:31 +0000 Subject: [PATCH 2/4] fix(app-shell): a bound field's read-only values say when they are the picklist's own only (objectui#10202) `GET /meta/picklist` serves a list's own options, not the ones other packages add with `picklistExtensions`. After a save the designer's draft carries no served copy, so the bound view lists the roster's own values; it now says the extensions' values are offered too. Measured live: after an edit of an object bound to a list with one extension, the view listed three of the field's four runtime values with nothing saying so. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- packages/app-shell/src/views/metadata-admin/i18n.ts | 3 +++ .../inspectors/ObjectFieldInspector.tsx | 13 +++++++++++++ .../ObjectFieldInspector.usePicklist-10202.test.tsx | 7 ++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 5178e02acd..fe7ec53e83 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2511,6 +2511,8 @@ const ENGINE_STRINGS_EN: Record = { 'designer.field.optionSourceInline': "This field's own options", 'designer.field.picklistBound': 'Options come from the picklist “{picklist}”. A field bound to a picklist has no options of its own: to offer another value, add it to the picklist in its package.', + 'designer.field.picklistOwnValues': + 'These are the picklist’s own values. Values other packages add to it are offered too.', 'designer.field.optMalformed': 'This option cannot be edited here', 'designer.field.optMalformed.notAnObject': 'It is not an option object.', 'designer.field.optMalformed.valueNotText': 'Its `value` is missing or is not text.', @@ -5431,6 +5433,7 @@ const ENGINE_STRINGS_ZH: Record = { 'designer.field.optionSourceInline': '本字段自己的选项', 'designer.field.picklistBound': '选项来自选项列表「{picklist}」。绑定了选项列表的字段没有自己的选项:要增加取值,请在选项列表所属的软件包里添加。', + 'designer.field.picklistOwnValues': '以上是选项列表自身的取值;其他软件包为它追加的取值同样可选。', 'designer.field.optMalformed': '此选项无法在这里编辑', 'designer.field.optMalformed.notAnObject': '它不是一个选项对象。', 'designer.field.optMalformed.valueNotText': '它的 `value` 缺失或不是文本。', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 3a986e084c..9ec02ccb6c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -990,6 +990,7 @@ export function ObjectFieldInspector({ )} @@ -2244,14 +2245,21 @@ function PicklistSourceField({ * editor: inline options are refused on a bound field, as the spec refuses * `picklist` with `options`. The options shown are the ones the field offers * (see `offeredOptions` in the inspector); nothing here writes. + * + * `ownValuesOnly` — the list shown is the picklist's OWN options from the + * roster, not the served field's resolved copy: `GET /meta/picklist` does not + * carry the options other packages add (`picklistExtensions`), so the note says + * those are offered too rather than letting the list read as complete. */ function BoundPicklistOptions({ picklistLabel, options, + ownValuesOnly, locale, }: { picklistLabel: string; options: Option[]; + ownValuesOnly: boolean; locale?: string; }) { return ( @@ -2270,6 +2278,11 @@ function BoundPicklistOptions({ ))} )} + {ownValuesOnly && options.length > 0 && ( +

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

+ )}
); } diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx index 881157a3e0..20c85fe57f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx @@ -140,6 +140,8 @@ describe('ObjectFieldInspector — use picklist (objectui#10202)', () => { const bound = screen.getByTestId('picklist-bound-options'); expect(bound).toHaveTextContent('Silver'); expect(bound).toHaveTextContent(/Options come from the picklist “Tier”/); + // The roster carries the list's OWN options only, so the list says it is not the whole offer. + expect(bound).toHaveTextContent('These are the picklist’s own values. Values other packages add to it are offered too.'); expect(addValueButton(), 'no inline options editor on a bound field').toBeNull(); expect(bound.querySelector('input, textarea, button')).toBeNull(); }); @@ -160,7 +162,10 @@ describe('ObjectFieldInspector — use picklist (objectui#10202)', () => { }, 'tier', ); - expect(screen.getByTestId('picklist-bound-options')).toHaveTextContent('Platinum'); + const bound = screen.getByTestId('picklist-bound-options'); + expect(bound).toHaveTextContent('Platinum'); + // The served copy IS the whole offer: no "own values only" note. + expect(bound).not.toHaveTextContent('own values'); expect(addValueButton()).toBeNull(); }); From 46320ed0c0b0c0aca1ca93b16c6b25d1f79e6ba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:03:50 +0000 Subject: [PATCH 3/4] refactor(app-shell): the picklist page's extension reader in a module of its own (objectui#10202) PicklistPreview.tsx exported `extensionsOf` beside the component, which react-refresh/only-export-components flags; and its pin imported an unused `waitFor` (object-ui/no-unused-imports, an error). The reader moves unchanged to previews/picklist-extensions.ts. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../previews/PicklistPreview.test.tsx | 5 +- .../previews/PicklistPreview.tsx | 58 +--------------- .../previews/picklist-extensions.ts | 69 +++++++++++++++++++ 3 files changed, 73 insertions(+), 59 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/previews/picklist-extensions.ts diff --git a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx index ec4d187e66..89fc88090e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.test.tsx @@ -16,7 +16,7 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; type Row = Record; @@ -70,7 +70,8 @@ vi.mock('../useMetadata', async (importOriginal) => { return { ...mod, useMetadataClient: () => client }; }); -import { PicklistPreview, extensionsOf } from './PicklistPreview'; +import { PicklistPreview } from './PicklistPreview'; +import { extensionsOf } from './picklist-extensions'; import { registerBuiltinPreviews } from './index'; import { getMetadataPreview } from '../preview-registry'; diff --git a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx index e87da9bd12..58cd86cee3 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx @@ -40,63 +40,7 @@ import { t as tr, tFormat } from '../i18n.js'; import { useMetadataClient } from '../useMetadata.js'; import { usePickerLoad, type LoadState } from '../loadState.js'; import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell.js'; - -/** One option as this page shows it: what the field offers, by label and value. */ -interface ShownOption { - value: string; - label: string; -} - -/** One `picklistExtensions` entry that names this list, with its declaring package. */ -export interface PicklistExtensionRow { - packageId: string; - packageName?: string; - options: ShownOption[]; -} - -/** The option shape a list or an extension declares, read defensively (the draft is untrusted). */ -function shownOptions(raw: unknown): ShownOption[] { - if (!Array.isArray(raw)) return []; - return raw.flatMap((o) => { - if (!o || typeof o !== 'object') return []; - const option = o as { value?: unknown; label?: unknown }; - if (option.value === undefined || option.value === null) return []; - const value = String(option.value); - return [{ value, label: typeof option.label === 'string' && option.label ? option.label : value }]; - }); -} - -/** - * The `picklistExtensions` entries naming `picklist`, across every installed - * package's manifest as `GET /meta/package` serves it. - * - * Exported for its pin: the row shape the package read serves is the input this - * page depends on, so the pin feeds it the measured shape. - */ -export function extensionsOf(picklist: string, packages: readonly unknown[]): PicklistExtensionRow[] { - const rows: PicklistExtensionRow[] = []; - for (const raw of packages) { - const row = - raw && typeof raw === 'object' && 'item' in raw - ? (raw as { item?: unknown }).item - : raw; - if (!row || typeof row !== 'object') continue; - const manifest = ((row as { manifest?: unknown }).manifest ?? row) as Record; - const packageId = typeof manifest.id === 'string' ? manifest.id : undefined; - if (!packageId || !Array.isArray(manifest.picklistExtensions)) continue; - for (const ext of manifest.picklistExtensions) { - if (!ext || typeof ext !== 'object') continue; - const entry = ext as { extend?: unknown; options?: unknown }; - if (entry.extend !== picklist) continue; - rows.push({ - packageId, - packageName: typeof manifest.name === 'string' && manifest.name ? manifest.name : undefined, - options: shownOptions(entry.options), - }); - } - } - return rows; -} +import { extensionsOf, shownOptions, type PicklistExtensionRow, type ShownOption } from './picklist-extensions.js'; function usePicklistExtensions(picklist: string): LoadState { const client = useMetadataClient(); diff --git a/packages/app-shell/src/views/metadata-admin/previews/picklist-extensions.ts b/packages/app-shell/src/views/metadata-admin/previews/picklist-extensions.ts new file mode 100644 index 0000000000..1d56102d7d --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/picklist-extensions.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The read side of the picklist page (objectui#10202), kept out of + * `PicklistPreview.tsx` so that module exports components only. + * + * `GET /meta/picklist/NAME` serves the owning list only; the options other + * packages add (`picklistExtensions`, `PicklistExtensionSchema`: `extend` + + * `options`) are served on the package read instead, each in the manifest of + * the package that declares it. {@link extensionsOf} reads them from there. + */ + +/** One option as this page shows it: what the field offers, by label and value. */ +export interface ShownOption { + value: string; + label: string; +} + +/** One `picklistExtensions` entry that names this list, with its declaring package. */ +export interface PicklistExtensionRow { + packageId: string; + packageName?: string; + options: ShownOption[]; +} + +/** The option shape a list or an extension declares, read defensively (the draft is untrusted). */ +export function shownOptions(raw: unknown): ShownOption[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((o) => { + if (!o || typeof o !== 'object') return []; + const option = o as { value?: unknown; label?: unknown }; + if (option.value === undefined || option.value === null) return []; + const value = String(option.value); + return [{ value, label: typeof option.label === 'string' && option.label ? option.label : value }]; + }); +} + +/** + * The `picklistExtensions` entries naming `picklist`, across every installed + * package's manifest as `GET /meta/package` serves it. + * + * Exported for its pin: the row shape the package read serves is the input this + * page depends on, so the pin feeds it the measured shape. + */ +export function extensionsOf(picklist: string, packages: readonly unknown[]): PicklistExtensionRow[] { + const rows: PicklistExtensionRow[] = []; + for (const raw of packages) { + const row = + raw && typeof raw === 'object' && 'item' in raw + ? (raw as { item?: unknown }).item + : raw; + if (!row || typeof row !== 'object') continue; + const manifest = ((row as { manifest?: unknown }).manifest ?? row) as Record; + const packageId = typeof manifest.id === 'string' ? manifest.id : undefined; + if (!packageId || !Array.isArray(manifest.picklistExtensions)) continue; + for (const ext of manifest.picklistExtensions) { + if (!ext || typeof ext !== 'object') continue; + const entry = ext as { extend?: unknown; options?: unknown }; + if (entry.extend !== picklist) continue; + rows.push({ + packageId, + packageName: typeof manifest.name === 'string' && manifest.name ? manifest.name : undefined, + options: shownOptions(entry.options), + }); + } + } + return rows; +} + From 3114a9f7d5d7dd142c79502166fc5f59b55336a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:40:01 +0000 Subject: [PATCH 4/4] fix(app-shell): the picklist roster and extension loaders are made once per mount, not keyed on a memoised client (objectui#10202) usePickerLoad re-runs its request whenever the loader's identity changes and re-enters `loading` as it does. The roster loader was a useMemo keyed on `client`, so a host whose client is not referentially stable re-minted it on every render: a render loop. Measured: ObjectFieldInspector.optionLabel.test.tsx, whose mock returns a fresh client per call, held a worker at 100% CPU (killed at 120 s); it passes with this change. Both loaders now live in useState, whose identity React guarantees (AGENTS.md #10). Pinned by a fresh-client mount that counts one roster read. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../inspectors/ObjectFieldInspector.tsx | 46 +++++++++++-------- ...tFieldInspector.usePicklist-10202.test.tsx | 43 +++++++++++++++-- .../previews/PicklistPreview.tsx | 9 ++-- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 9ec02ccb6c..b50a16ca66 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -2182,28 +2182,36 @@ interface PicklistRosterEntry { */ function usePicklistRoster(): LoadState { const client: MetadataClient = useMetadataClient(); - const load = React.useMemo( - () => async (): Promise => { - const items = await client.list>('picklist'); - return (items ?? []) - .map((raw) => - raw && typeof raw === 'object' && 'item' in raw - ? ((raw as { item?: Record }).item ?? {}) - : (raw as Record), - ) - .filter((i): i is Record & { name: string } => typeof i?.name === 'string' && i.name !== '') - .map((i) => ({ - name: i.name, - label: typeof i.label === 'string' && i.label ? i.label : i.name, - options: Array.isArray(i.options) ? (i.options as unknown[]) : [], - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, - [client], - ); + // The loader is made ONCE per mount and held in state, whose identity React + // guarantees, never in a `useMemo` keyed on `client` (AGENTS.md #10). + // `usePickerLoad` re-runs its request whenever the loader's identity changes + // and re-enters `loading` as it does, so a loader minted per render is a + // render loop wherever the host's client is not referentially stable — + // measured: an inspector suite whose mock returns a fresh client per call + // spun one worker at 100% CPU until killed. The list is read for the client + // this inspector mounted with. + const [load] = React.useState(() => () => fetchPicklistRoster(client)); return usePickerLoad(load); } +/** `GET /meta/picklist`, reduced to what the picker and the bound view read. */ +async function fetchPicklistRoster(client: MetadataClient): Promise { + const items = await client.list>('picklist'); + return (items ?? []) + .map((raw) => + raw && typeof raw === 'object' && 'item' in raw + ? ((raw as { item?: Record }).item ?? {}) + : (raw as Record), + ) + .filter((i): i is Record & { name: string } => typeof i?.name === 'string' && i.name !== '') + .map((i) => ({ + name: i.name, + label: typeof i.label === 'string' && i.label ? i.label : i.name, + options: Array.isArray(i.options) ? (i.options as unknown[]) : [], + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + /** * Where a choice field's options come from: its own inline list, or a served * picklist named here. A closed list — the field can only name a picklist the diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx index 20c85fe57f..c884cdb03c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.usePicklist-10202.test.tsx @@ -40,15 +40,23 @@ const SERVED_PICKLISTS: Row[] = [ const roster = vi.hoisted(() => ({ answer: null as null | (() => Promise), + /** Hand the inspector a NEW client object on every call, as an unmemoised host would. */ + freshClient: false, + /** How many times the roster was requested. */ + reads: 0, })); vi.mock('../useMetadata', async (importOriginal) => { const mod = await importOriginal(); - const client = { - list: async (type: string) => (type === 'picklist' && roster.answer ? roster.answer() : []), + const make = () => ({ + list: async (type: string) => { + if (type === 'picklist') roster.reads += 1; + return type === 'picklist' && roster.answer ? roster.answer() : []; + }, listDrafts: async () => [], - }; - return { ...mod, useMetadataClient: () => client }; + }); + const stable = make(); + return { ...mod, useMetadataClient: () => (roster.freshClient ? make() : stable) }; }); vi.mock('../previews/useObjectFields', async (importOriginal) => { @@ -60,6 +68,8 @@ import { ObjectFieldInspector } from './ObjectFieldInspector'; beforeEach(() => { roster.answer = async () => SERVED_PICKLISTS.map((p) => ({ ...p })); + roster.freshClient = false; + roster.reads = 0; // Radix Select reads pointer capture, which the DOM double does not implement. for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { if (!(m in Element.prototype)) { @@ -205,6 +215,31 @@ describe('ObjectFieldInspector — use picklist (objectui#10202)', () => { await waitFor(() => expect(sourcePicker()).toHaveTextContent('acme_gone (not found)')); }); + it('reads the roster once per mount, even when the host hands it a fresh client on every render (AGENTS.md #10)', async () => { + // A loader keyed on the client's identity re-ran on every render, and each + // run re-entered `loading` and rendered again: a render loop that held a + // worker at 100% CPU — measured on `ObjectFieldInspector.optionLabel.test.tsx`, + // whose mock returns a fresh client per call. Mounted the way that suite + // mounts it (no stateful host), with real timers only. + roster.freshClient = true; + render( + {}} + onClearSelection={() => {}} + onSelectionChange={() => {}} + readOnly={false} + locale="en-US" + />, + ); + await new Promise((r) => setTimeout(r, 300)); + expect(screen.getByRole('combobox', { name: 'Options from' })).toBeInTheDocument(); + expect(roster.reads).toBe(1); + }); + it('a failed roster read says so and keeps the field\'s own options choosable; it is never drawn as "no picklists"', async () => { roster.answer = async () => { throw new Error('HTTP 503'); diff --git a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx index 58cd86cee3..1fb9e4f916 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PicklistPreview.tsx @@ -44,9 +44,12 @@ import { extensionsOf, shownOptions, type PicklistExtensionRow, type ShownOption function usePicklistExtensions(picklist: string): LoadState { const client = useMetadataClient(); - const load = React.useMemo( - () => (picklist ? async () => extensionsOf(picklist, (await client.list('package')) ?? []) : null), - [client, picklist], + // Made once per mount and held in state (AGENTS.md #10): a loader keyed on a + // memoised `client` re-runs `usePickerLoad` on every render of a host whose + // client is not referentially stable. The edit page keys this preview per + // item, so one mount is one picklist. + const [load] = React.useState(() => + picklist ? async () => extensionsOf(picklist, (await client.list('package')) ?? []) : null, ); return usePickerLoad(load); }