Skip to content

Commit da35453

Browse files
fix(studio): object writers seeded from a served read drop a picklist-bound field's served options (objectui#11692) (#11706)
Fixes #11692 Clause-②: yes Object writers outside the two objectui#10202 designers seed their PUT from a served object read. The runtime serves a picklist-bound field with `picklist` and the `options` it resolved from the list, and the authoring door refuses that pair for the whole object (`422 INVALID_METADATA` at `fields.FIELD.options`). Each writer the card lists now either drops those served options before its PUT, or is shown here not to seed from a served read. A fifth writer of the same class, `EmbeddedItemEditor`, was found during the work. The seat added it to the claim's file surface (comment 6012529702), and it is fixed the same way. Dispatched work: claim 6011332561 on objectui#11692, PM session `https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju`. ## What changes **The helper moves to `@object-ui/data-objectstack` (new export, the reason for `Clause-②: yes`).** - `dropServedPicklistOptions` moves from `app-shell/src/views/metadata-admin/picklist-binding.ts` to `data-objectstack/src/picklist-binding.ts` (a `git mv`, with its test) and is exported from the package barrel. - Why it moves: `@object-ui/plugin-designer` does not depend on `@object-ui/app-shell`, and `app-shell` lists `plugin-designer` as a peer, so the two Setup pages could not import it where it lived. Both packages already depend on `data-objectstack`. That package also owns the client that serves the read and carries the write, the same reason `extractDraftBody` lives there. - The two existing callers, `builtinComponents.tsx` (the metadata-admin `object` resource's `fromDraft`) and `StudioDesignSurface.tsx`, now import it from `@object-ui/data-objectstack`. No copy is left in `app-shell`, which never exported it. - Built declaration, read from `packages/data-objectstack/dist/index.d.ts` after `turbo run build`: `declare function dropServedPicklistOptions` with type parameter `T extends Record of string to unknown`, taking `body: T` and returning `T`. It appears in the barrel's export list beside `extractDraftBody`. The type parameter is spelled out in words here because GitHub strips angle brackets from PR bodies. - ⛔ It is not applied at a door. `MetadataClient.save` is unchanged, which leaves option B ruled out as triage directed. The barrel comment and the module docblock both say the export is for WRITERS. A door cannot tell a served copy from an authored pair, and the authored pair must stay the server's loud refusal. **Per writer (card "Done when"):** | Writer | Seeds from a served read? | Now | |---|---|---| | `PackageOwdOverviewPanel` `doSave` | yes: `getDraft` body, else `layered.effective` | `dropServedPicklistOptions(next)` before `client.save` | | `MetadataFieldsPage` `handleFieldsChange` | yes: `client.get('object')`, and `carryOver` / `carryPreservedField` carry every per-field key back | `dropServedPicklistOptions(mergedObject)`. The page has no options editor, so a bound field's `options` can only be the served copy | | `MetadataObjectsPage` `handleObjectsChange` | yes: `client.list('object')` spread as the merge base | `dropServedPicklistOptions(merged)` | | `EmbeddedItemEditor` `doSave` (opened from an object's fields, indexes or validations) | yes: it re-reads `client.layered(parentType, parentName).effective`, splices the edited item in, and PUTs the whole parent | an `object` parent goes through `dropServedPicklistOptions(updated)`. Other parent types are sent exactly as before | | `MetadataService.saveFields` | yes: `client.meta.getItem('object')` and the per-field carry-over | `dropServedPicklistOptions(updatedObject)`. `DesignerFieldDefinition` declares no `picklist`, so this method can neither author nor remove a binding | | `MetadataService.saveObject` | **no**: it reads nothing, and builds from the caller's `obj` and `existingFields` | unchanged, with the reason in its docblock | | `MetadataService.saveMetadataItem` | **no**: it reads nothing, and `data` is the caller's | unchanged, with the reason in its docblock | The list read resolving the binding is a source reading of objectstack, not a live measurement. objectstack's `metadata-protocol` `foldObjectExtendersFromRegistry` docblock says the list read "reads `registry.listItems`, whose object branch resolves through the same fold", and `SchemaRegistry.foldExtendersOntoDefinition` ends in `resolvePicklistFields`. Triage's correction stands: `runtime-metadata-persistence.ts` saves no object, so it is untouched. ## Pins (a 422-refusing transport, as objectui#10202's pins are) Each new test drives a real `MetadataClient`, or for `MetadataService` a real `ObjectStackAdapter` and SDK. The transport double answers a PUT whose fields carry `picklist` beside `options` with the door's `422 INVALID_METADATA` envelope, so every claim is read off the wire: - `PackageOwdOverviewPanel.picklistServedOptions-11692.test.tsx`: an OWD change on an object with a bound field, seeded from `layered.effective` and from a served draft. The door accepts the body, the bound field goes out as `picklist` only, the inline select and the text field go out byte-identical, and the OWD edit lands. - `MetadataFieldsPage.picklistServedOptions-11692.test.tsx`: relabel an unrelated field. Both the designable bound `select` and a carried-through bound `multiselect` leave without `options`, and the inline select keeps its options. - `MetadataObjectsPage.picklistServedOptions-11692.test.tsx`: relabel the object. Same assertions. - `EmbeddedItemEditor.picklistServedOptions-11692.test.tsx`: the editor is mounted over a served object with a bound `tier`. Two cases: saving another item (`amount`), and an untouched save of the bound field itself. In both, the door accepts the parent, `tier` goes out as `picklist` only, and the inline select goes out byte-identical. - `MetadataService.picklistServedOptions-11692.test.ts`: - `saveFields` with a field list built from the served read is accepted, and the bound field has no `options`. - `saveObject` and `saveMetadataItem` issue no read, and an authored pair still reaches the door and is refused, asserted on the SDK error's `code: 'INVALID_METADATA'` and `httpStatus: 422`. - The moved `picklist-binding.test.ts` is unchanged apart from its header. It still reads the contract off the installed `@objectstack/spec`. **Ablation.** Each fix was committed first (`51bb8bb` for the four writers, `32b8c2e` for `EmbeddedItemEditor`). Each writer's helper call was then removed through objectstack's `scripts/ablation-replace.mjs`, which wraps the run, verifies that the mutation landed on disk, and restores from `HEAD`. Every pin went red for the right reason, and every restore was proven by blob hash equal to HEAD and an empty `git diff HEAD`. | Mutation | Result | Failure message | |---|---|---| | OWD `dropServedPicklistOptions(next)` to `next` | 2 failed of 2 | `refused at fields.tier.options: expected 422 to be 200` | | fields page `dropServedPicklistOptions(mergedObject)` to `mergedObject` | 1 failed of 1 | `refused at fields.tier.options, fields.regions.options` | | objects page `dropServedPicklistOptions(merged)` to `merged` | 1 failed of 1 | `refused at fields.tier.options` | | `saveFields` `dropServedPicklistOptions(updatedObject)` to `updatedObject` | 1 failed, 2 passed of 3 | the SDK's `failed spec validation` throw | | `EmbeddedItemEditor` `dropServedPicklistOptions(updated)` to `updated` (at `32b8c2e`) | 2 failed of 2 | `refused at fields.tier.options: expected 422 to be 200` | The `saveObject` and `saveMetadataItem` pins stay green under that last mutation, as they should. The first attempt at the last three ablations was REFUSED by the tool and ran no test, because the replacement was a substring of the anchor. They were re-anchored on the whole `save(...)` line and re-run. The table shows the re-run. No `dist` build sits in the ablation path. The writers are imported from `src` by relative path, and the root vitest config aliases `@object-ui/data-objectstack` to its `src`. ## Gates **Patch round (fifth writer), at branch head `32b8c2e`.** This head includes the merge of `main` at `3409fe8`, which is a merge commit, not a rebase. | Gate | Exit | Verdict line | |---|---|---| | `turbo run build --filter=@object-ui/app-shell^... --concurrency=2` | 0 | `Tasks: 28 successful, 28 total` | | `pnpm --filter @object-ui/app-shell run type-check` | 0 | | | `pnpm --filter @object-ui/app-shell run lint` | 0 | 0 errors. The warning count is unchanged from `bc90d2f`, and the new test has no hit | | `vitest run` of the 6 pin files plus the 3 existing `EmbeddedItemEditor` suites | 0 | `Test Files 9 passed (9)`, `Tests 39 passed (39)` | | `check:new-line-citations` / `check:control-bytes` / `check:changeset-claims` / `check:pending-changeset-literals` / `check:metadata-write-doors` | all 0 | `0 new citation(s)` and `3 can carry an object document, 3 reach assertObjectMetadataWritable` | | `check-changeset-no-major` / `check-changeset-presence` / `check:self-import` / `check:phantom-deps` / `check-vi-mock-{specifiers,inherit,override-shape}` / `check:test-path-roots` | all 0 | `15 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)` | **Round 1, at branch head `bc90d2f`.** These readings were not re-taken in the patch round. All at `bc90d2f` unless stated. Heavy runs went through `os-verify-lock.sh`. Every exit code was captured before any pipe. | Gate | Exit | Verdict line | |---|---|---| | `turbo run build --filter=@object-ui/app-shell^... --filter=@object-ui/plugin-designer^... --concurrency=2` (at `51bb8bb`, whose `src` is identical to `bc90d2f`) | 0 | `Tasks: 28 successful, 28 total` | | `pnpm --filter @object-ui/data-objectstack run type-check` | 0 | | | `pnpm --filter @object-ui/plugin-designer run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) | 0 | the new tests are program inputs (`--listFiles`) | | `pnpm --filter @object-ui/app-shell run type-check` (same two legs) | 0 | the new tests are program inputs (`--listFiles`) | | `pnpm --filter @object-ui/{data-objectstack,plugin-designer,app-shell} run lint` | 0 / 0 / 0 | 0 errors. No warning on a line this PR adds | | `vitest run` of the 5 new or moved test files | 0 | `Test Files 5 passed (5)`, `Tests 13 passed (13)` | | `vitest run packages/data-objectstack/ packages/plugin-designer/` plus 126 `app-shell` files (see below) | 0 | `Test Files 247 passed (247)`, `Tests 2342 passed (2342)` | | `check:self-import` / `check:phantom-deps` / `check:unused-deps` | 0 / 0 / 0 | | | `check:readme-exports` | 1, a precondition (see below) | the new import is judged `real` (`--list`) | | README snippet, compiled `--strict` against the built `dist` | 0 | negative control (a misspelt name): TS2724, exit 2 | | `check:new-line-citations` | 0 | `0 new citation(s)` | | `check:control-bytes` | 0 | `OK` | | `check:metadata-write-doors` | 0 | `3 can carry an object document, 3 reach assertObjectMetadataWritable` | | `check:changeset-claims` / `check:pending-changeset-literals` | 0 / 0 | `No pending changeset names a file this change touches.` | | `check-changeset-no-major` / `check-changeset-fixed` / `check-changeset-presence` | 0 / 0 / 0 | `13 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)` | | `check:unreferenced-sources` / `check:test-path-roots` / `check-vi-mock-{specifiers,inherit,override-shape}` / `check:esm-specifiers` / `check:doc-fences` / `check:designer-field-key-parity` / `check-type-check-coverage` / `check-lint-coverage` / `check:spec-symbols` / `check:component-surface-parity` (report-only) | all 0 | | Notes on the table: - **Narrowed test run, declared.** `app-shell` has 1025 test files. A full run of all three packages was started and held the shared lock for 29 minutes without finishing, with other seats queued behind it, so I stopped it (my own PID). I re-ran a narrowed set instead: all of `data-objectstack` and `plugin-designer`, and in `app-shell` every test under `src/services/` and `src/views/studio-design/`, every test that names `StudioDesignSurface`, `builtinComponents`, `PackageOwdOverviewPanel`, `MetadataService`, `picklist-binding`, `dropServedPicklistOptions` or `register-builtins`, and the objectui#10202 suites. That set is derived from direct importers. It is blind to a test that reaches a touched module through a third module, so the full `app-shell` suite is left to CI. `app-shell`'s barrel surface is byte-unchanged. `data-objectstack` gains one export and loses none. - **`check:readme-exports` exit 1 is not a measurement of this change.** It is the gate's "type entry not on disk" refusal for 8 packages outside this build's closure (`plugin-gantt`, `plugin-map`, `plugin-markdown`, `plugin-timeline` and others). With `--list` it exits 2 (PRECONDITION NOT MET) and judges `packages/data-objectstack/README.md`'s `dropServedPicklistOptions` import `real`. - **NOT MEASURED: `check:doc-snippets`, reason: its build closure is every package.** The one new README block was compiled `--strict` against the built `data-objectstack` dist instead, with the negative control shown in the table. - **NOT MEASURED: `check:eager-closure`, reason: it needs a full console build.** See Acceptance notes. ## Acceptance notes - **The fifth writer, `EmbeddedItemEditor`, is fixed here** after the claim amendment (comment 6012529702). Its wrap covers the edited item too, the same rule the four writers and the Studio data page apply: every field that names a picklist leaves without `options`. If the drawer's generic field form offers `options` on a bound field, an edit made there is dropped while the binding stays, because the editor cannot tell the served copy from an edit. Unbinding a field stays the job of the select-field editor's "Options from" picker (objectui#10202). - `MetadataService` has no in-repo caller of `saveObject`, `saveFields` or `saveMetadataItem`. It is public API through `useMetadataService`. The `saveFields` fix and the two pins of "no read, the pair still refused" are therefore measured on the method, not on a screen. - `check:eager-closure` was not run locally (it needs a full console build). The helper moved into a package that `apps/console/src/dataSource.ts` already imports statically, so no new module joins the eager graph. That claim is read from source, not measured. --- _Generated by [Claude Code](https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent de96f3d commit da35453

17 files changed

Lines changed: 942 additions & 21 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/data-objectstack': minor
3+
'@object-ui/app-shell': patch
4+
'@object-ui/plugin-designer': patch
5+
---
6+
7+
Objects with a picklist-bound field save again from the OWD overview, the Setup fields and objects pages, the metadata-admin embedded-item editor, and `MetadataService.saveFields` (objectui#11692).
8+
9+
The runtime serves a field that names a shared picklist (`picklist: 'NAME'`) with the options it resolved from the list. The authoring door refuses `options` beside `picklist`, with `422 INVALID_METADATA` at `fields.FIELD.options`, and the refusal covers the whole object. objectui#10202 fixed the two designers that host the select-field editor. These writers also seed their save from a served object read, so they sent the resolved options back and were refused, whatever had been edited:
10+
11+
- **Studio → Access → OWD overview.** Changing an object's sharing model and saving was refused.
12+
- **The Setup fields page** (`MetadataFieldsPage`). Editing any field of such an object was refused.
13+
- **The Setup objects page** (`MetadataObjectsPage`). Relabelling such an object was refused.
14+
- **The metadata-admin embedded-item editor** (`EmbeddedItemEditor`, opened from an object's fields, indexes or validations). Saving any item of such an object was refused.
15+
- **`MetadataService.saveFields`.** A field list built from the served object was refused.
16+
17+
Each now leaves `options` out of every field that names a picklist, before the PUT. A field without `picklist` keeps its inline `options` unchanged, and no other key is touched.
18+
19+
`dropServedPicklistOptions` is now exported from `@object-ui/data-objectstack`. It is the served-to-authored conversion these writers apply, and it moves here from `@object-ui/app-shell` (it was not exported there), because `@object-ui/plugin-designer` does not depend on `app-shell`. It is for code that builds an object PUT from a served read. `MetadataClient.save` does not apply it, and neither do `MetadataService.saveObject` and `MetadataService.saveMetadataItem`, which read nothing: a body that pairs `picklist` with `options` without a served read behind it is still refused by the server, with its prescription.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11692 — `MetadataService`'s three object writes, and a field that
11+
* names a shared picklist.
12+
*
13+
* The runtime serves a picklist-bound field with `picklist` AND the options it
14+
* resolved from the list; the authoring door refuses the two together for the
15+
* whole object (`422 INVALID_METADATA` at `fields.FIELD.options`). The card's
16+
* "Done when" asks each writer for one of two answers, and this file pins which
17+
* one each method gives:
18+
*
19+
* - `saveFields` SEEDS from a served read (its GET, and the per-field
20+
* carry-over that brings the binding back out), so it drops the served
21+
* `options` on every bound field before its PUT.
22+
* - `saveObject` and `saveMetadataItem` read NOTHING: every key they send is
23+
* the caller's, so they cannot tell a served copy from an authored pair and
24+
* must not strip — the authored pair stays the server's loud refusal, which
25+
* is the authoring check objectui#10202 relies on. Pinned as "no read" plus
26+
* "the pair still reaches the door and is refused".
27+
*
28+
* A real `ObjectStackAdapter` and SDK over a fetch double that serves the
29+
* object and refuses a PUT body the way the authoring door does, so every
30+
* claim is read off the WIRE.
31+
*/
32+
33+
import { describe, expect, it, vi } from 'vitest';
34+
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
35+
import type { DesignerFieldDefinition, ObjectDefinition } from '@object-ui/types';
36+
import { MetadataService, type FieldMetadataPayload } from './MetadataService';
37+
38+
type Row = Record<string, unknown>;
39+
40+
const TIER_OPTIONS = [
41+
{ label: 'Gold', value: 'gold' },
42+
{ label: 'Platinum', value: 'platinum' },
43+
];
44+
const STATUS_OPTIONS = [
45+
{ label: 'Open', value: 'open' },
46+
{ label: 'Closed', value: 'closed' },
47+
];
48+
49+
/** The object as the runtime SERVES it. */
50+
const SERVED: Row = {
51+
name: 'acme_account',
52+
label: 'Account',
53+
fields: {
54+
tier: { type: 'select', label: 'Tier', picklist: 'acme_tier', options: TIER_OPTIONS },
55+
status: { type: 'select', label: 'Status', options: STATUS_OPTIONS },
56+
},
57+
};
58+
59+
/** The authoring door's refusal for a field carrying both keys. */
60+
function doorRefusal(body: Row): string[] {
61+
return Object.entries((body.fields ?? {}) as Record<string, Row>)
62+
.filter(([, def]) => def && def.picklist !== undefined && def.options !== undefined)
63+
.map(([name]) => `fields.${name}.options`);
64+
}
65+
66+
function json(body: unknown, status = 200): Response {
67+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
68+
}
69+
70+
/** A real adapter whose transport serves {@link SERVED} and refuses the pair. */
71+
function makeAdapter() {
72+
const puts: Array<{ body: Row; status: number }> = [];
73+
let gets = 0;
74+
const adapter = new ObjectStackAdapter({
75+
baseUrl: 'http://test.local',
76+
fetch: vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
77+
const method = (init?.method ?? 'GET').toUpperCase();
78+
if (method === 'PUT') {
79+
const body = JSON.parse(String(init?.body ?? '{}')) as Row;
80+
const refused = doorRefusal(body);
81+
const status = refused.length > 0 ? 422 : 200;
82+
puts.push({ body, status });
83+
return status === 422
84+
? json(
85+
{
86+
error: 'failed spec validation',
87+
code: 'INVALID_METADATA',
88+
issues: refused.map((path) => ({
89+
path,
90+
code: 'custom',
91+
message: '`picklist` and `options` cannot both be declared. Keep `picklist` and delete `options`.',
92+
})),
93+
},
94+
422,
95+
)
96+
: json({ success: true });
97+
}
98+
gets += 1;
99+
return json({ item: SERVED });
100+
}) as unknown as typeof fetch,
101+
});
102+
return { adapter, puts, getCount: () => gets };
103+
}
104+
105+
describe('MetadataService — a picklist-bound field and the three object writes (objectui#11692)', () => {
106+
it('saveFields seeds from a served read, so a field list built from that read and left untouched goes out accepted, the bound field without `options`', async () => {
107+
const { adapter, puts, getCount } = makeAdapter();
108+
// What a field designer holds after loading the served object: both fields,
109+
// the bound one with the resolved options the read handed it.
110+
const designerFields: DesignerFieldDefinition[] = [
111+
{ id: 'tier', name: 'tier', label: 'Tier', type: 'select', options: TIER_OPTIONS },
112+
{ id: 'status', name: 'status', label: 'Status', type: 'select', options: STATUS_OPTIONS },
113+
];
114+
115+
await new MetadataService(adapter).saveFields('acme_account', designerFields);
116+
117+
expect(getCount(), 'the served read this writer seeds from').toBeGreaterThan(0);
118+
expect(puts).toHaveLength(1);
119+
const [put] = puts;
120+
expect(put!.status, `refused at ${doorRefusal(put!.body).join(', ')}`).toBe(200);
121+
const fields = put!.body.fields as Record<string, Row>;
122+
// The binding the carry-over brought back stays; its served options do not.
123+
expect(fields.tier).toMatchObject({ type: 'select', label: 'Tier', picklist: 'acme_tier' });
124+
expect(fields.tier).not.toHaveProperty('options');
125+
// A field that names no picklist keeps its options exactly.
126+
expect(fields.status!.options).toEqual(STATUS_OPTIONS);
127+
});
128+
129+
it('saveObject reads nothing, so it strips nothing: an authored pair reaches the door and comes back refused', async () => {
130+
const { adapter, puts, getCount } = makeAdapter();
131+
const obj: ObjectDefinition = { id: 'acme_account', name: 'acme_account', label: 'Account', isSystem: false, fieldCount: 1 };
132+
// The binding is not a declared `FieldMetadataPayload` member, which is the
133+
// point: only a caller holding a wider value can hand one in.
134+
const authored = { name: 'tier', type: 'select', label: 'Tier', picklist: 'acme_tier', options: TIER_OPTIONS };
135+
136+
await expect(
137+
new MetadataService(adapter).saveObject(obj, [authored as FieldMetadataPayload]),
138+
).rejects.toMatchObject({ code: 'INVALID_METADATA', httpStatus: 422 });
139+
140+
expect(getCount(), 'saveObject issues no read').toBe(0);
141+
expect(puts).toHaveLength(1);
142+
expect((puts[0]!.body.fields as Record<string, Row>).tier).toMatchObject({ picklist: 'acme_tier', options: TIER_OPTIONS });
143+
});
144+
145+
it('saveMetadataItem reads nothing, so it strips nothing: the pair it is handed reaches the door and comes back refused', async () => {
146+
const { adapter, puts, getCount } = makeAdapter();
147+
148+
await expect(
149+
new MetadataService(adapter).saveMetadataItem('object', 'acme_account', SERVED),
150+
).rejects.toMatchObject({ code: 'INVALID_METADATA', httpStatus: 422 });
151+
152+
expect(getCount(), 'saveMetadataItem issues no read').toBe(0);
153+
expect(puts).toHaveLength(1);
154+
expect((puts[0]!.body.fields as Record<string, Row>).tier).toMatchObject({ picklist: 'acme_tier', options: TIER_OPTIONS });
155+
});
156+
});

‎packages/app-shell/src/services/MetadataService.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { stripReadDecorations } from '@objectstack/spec/kernel';
2020
import {
2121
assertObjectMetadataWritable,
22+
dropServedPicklistOptions,
2223
RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
2324
viewItemObjectName,
2425
type ObjectStackAdapter,
@@ -704,6 +705,14 @@ export class MetadataService {
704705
* The object binding comes from the body being written, via the same
705706
* accessor `listViewOverrides` narrows those rows by — not from a fourth
706707
* private copy of "which object is this?".
708+
*
709+
* ⛔ No `dropServedPicklistOptions` here (objectui#11692). This method reads
710+
* nothing: `data` is the caller's, so it cannot tell a served copy of a
711+
* picklist-bound field from an author who wrote `picklist` and `options`
712+
* together, and the second must stay the server's loud 422 with its
713+
* prescription. A caller that seeded `data` from a served object read applies
714+
* the conversion itself. Pinned (no read, the pair still refused) in
715+
* `MetadataService.picklistServedOptions-11692.test.ts`.
707716
*/
708717
async saveMetadataItem(category: string, name: string, data: Record<string, unknown>): Promise<void> {
709718
await this.putMetadataItem(category, name, data);
@@ -804,6 +813,14 @@ export class MetadataService {
804813
* same way — but that builds capability for a path with zero measured
805814
* pull and makes this parameter redundant, which then wants retiring on
806815
* its own terms (ADR-0049 shape).
816+
*
817+
* ⛔ And, for the same reason it does not fetch, no `dropServedPicklistOptions`
818+
* (objectui#11692): this method reads nothing, so every key it sends is one
819+
* the caller handed it, and it cannot tell a served copy from an authored
820+
* `picklist` + `options` pair — which must stay the server's loud 422.
821+
* `FieldMetadataPayload` declares no `picklist`, so a fresh typed field
822+
* literal cannot carry the binding here at all. Pinned (no read, the pair
823+
* still refused) in `MetadataService.picklistServedOptions-11692.test.ts`.
807824
*/
808825
async saveObject(obj: ObjectDefinition, existingFields: FieldMetadataPayload[]): Promise<void> {
809826
const payload = toObjectPayload(obj, existingFields);
@@ -894,6 +911,22 @@ export class MetadataService {
894911
* previous entries ride in on the very document this method already
895912
* fetched, so `toFieldPayload` merges onto them; see there for the mirror
896913
* property that keeps a CLEARED designer property cleared.
914+
*
915+
* …and a fifth, pinned in `MetadataService.picklistServedOptions-11692.test.ts`:
916+
*
917+
* - **A picklist-bound field leaves without its served `options`**
918+
* (objectui#11692). The fetched document is the SERVED one, so a field
919+
* naming a shared picklist carries `picklist` beside the options the
920+
* runtime resolved from the list, and the carry-over above brings the
921+
* binding back out. The authoring door refuses `options` beside
922+
* `picklist` for the WHOLE object, so the body passes through
923+
* `dropServedPicklistOptions` before the PUT. The binding can only have
924+
* come from that read: `DesignerFieldDefinition` declares no `picklist`,
925+
* so this method has no way to author one and none to remove one. A
926+
* bound field's `options` are therefore never half of an authored pair
927+
* here; they are the served copy, or a designer-model value the door
928+
* refuses beside the binding whatever it holds. Fields without a
929+
* `picklist` keep their `options` exactly as before.
897930
*/
898931
async saveFields(objectName: string, fields: DesignerFieldDefinition[]): Promise<void> {
899932
const client = this.adapter.getClient();
@@ -944,7 +977,7 @@ export class MetadataService {
944977
fields: toFieldsMap(fields.map((field) => toFieldPayload(field, previousFieldEntry(previousFields, field.name)))),
945978
}) as Record<string, unknown>;
946979

947-
await this.putMetadataItem('object', objectName, updatedObject);
980+
await this.putMetadataItem('object', objectName, dropServedPicklistOptions(updatedObject));
948981
this.adapter.invalidateCache(`object:${objectName}`);
949982
}
950983

‎packages/app-shell/src/services/builtinComponents.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import {
2525
isAggregatedViewContainer,
2626
viewDisplayType,
2727
} from '../views/metadata-admin/view-item-normalize.js';
28-
import { dropServedPicklistOptions } from '../views/metadata-admin/picklist-binding.js';
28+
// objectui#11692 - the helper moved beside the metadata client, so the
29+
// plugin-designer writers share it; `@object-ui/data-objectstack` is already on
30+
// the console's eager path (its data source), so this adds no eager chunk.
31+
import { dropServedPicklistOptions } from '@object-ui/data-objectstack';
2932

3033
/* -------------------------------------------------------------------------- */
3134
/* 1) Top-level admin pages — bound to `metadata:directory` + `metadata:resource` */

0 commit comments

Comments
 (0)