Skip to content

Commit 178b523

Browse files
fix(app-shell): Studio API tab example body leaves out owner_id and optional references (#27613)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDiLk5zdcKrMuPQwcCm6WjS)_ ## 维护者速读 - 改了什么:Studio 对象「API」标签页的示例请求体不再放 `owner_id` 和可选的引用字段;必填引用字段改成一眼就能认出的占位符(尖括号包住的 `id of a showcase_project record`)。 - 为什么改:原来每个引用字段都填 `"related_record_id"`,这个 id 不对应任何记录,复制出来的新建/更新 cURL 在几乎所有对象上都被 400 `reference_not_found` 拒掉。 - 风险与回滚:只改示例体的生成,不碰任何写入路径;可选字段仍在体下方的提示里列名。回滚即还原 `ObjectApiPanel.tsx` 一个文件。 - 先看:`ObjectApiPanel.tsx` 的 `buildExampleBody`(哪些字段进体)→ 同文件 `referencePlaceholderFor`(必填引用的占位符)→ 钉住测试新增的 objectstack#27490 一节。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx`,预期 13 个测试全绿。 - 需要你做的:无,席位落地。 Fixes #27490 Clause-②: no Metadata-protocol: none ## What changes `ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx`, `buildExampleBody` and the reference branch of `exampleElementFor`: - **Optional fields that never join the body:** a field the platform adds (`system: true`, such as `owner_id`, which the platform fills in when it is left out), and a reference field (`REFERENCE_VALUE_TYPES`: `lookup`, `master_detail`, `user`, `tree`). Both come from the field metadata the draft already carries; there is no list of field names. They are still named in the panel's "Optional fields not shown" note, so every writable field is either in the body or named. - **A required reference** stays in the body, since a create without it is refused. Its value is now the text `id of a TARGET record` wrapped in angle brackets, the target read through the spec's `referenceTargetOf` (so a `user` field names `sys_user`), the same angle-bracket form as the cURL's token placeholder. Before, it was `"related_record_id"`, which reads like a working value. - Unchanged: the other sample values, required-first order, the cap of eight, and one body for both Create and Update (no second generator). The previous "own fields before platform fields" ordering is now a stronger rule: platform fields are never shown when optional. ## Tests `ObjectApiPanel.exampleBody-11803.test.tsx` (the existing pin) is extended with a `objectstack#27490` describe block of 5 tests. Its oracle is the write path's own reference check: `assertReferencesResolve` in `@objectstack/objectql` resolves a supplied value only on a field that `referenceTargetOf` answers a target for. So for every key of the shown body the test asserts `referenceTargetOf(def)` is undefined (nothing for that check to refuse) and the value still parses under `valueSchemaFor(def, 'stored')`. - owner_id is left out even when the body has room, and is named in the note. - When no writable field is shown, the body block still renders (an empty body) with the note naming the fields left out. Before, the block and its note were hidden for an empty body; with owner_id now never shown, an object whose writable fields are all optional references would otherwise lose the note. - Every optional reference is left out, on the showcase Account and on the field zoo without its one required reference. - A required reference shows the placeholder naming its target (`showcase_project`; `sys_user` for a `user` field). - The copied Create (POST) and Update (PATCH) cURL carry the shown body and no `owner_id`. Two existing tests are adjusted, not weakened: the per-type zoo test reads a reference type's value shape with the field marked required (an optional one is no longer in the body), and the multi-value test marks `f_lookups` required for the same reason. <details><summary>Test and ablation readings (head 8e49824)</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx`: Test Files 1 passed (1), Tests 13 passed (13). - Ablation A, through `scripts/ablation-replace.mjs`: the never-shown predicate replaced by `false` (anchor 1 to 0, blob f6f17e6 to bdfdf47c). Tests 4 failed, 8 passed: the three new body tests and the existing ordering test. Restored: blob equals HEAD, `git diff HEAD` empty. - Ablation B: the reference placeholder replaced by the old `'related_record_id'` (blob f6f17e6 to a11e246e). Tests 1 failed (the placeholder test), 11 passed. Restored the same way. - Ablation C (head 8e49824): the empty-body condition's `|| omitted.length > 0` replaced by a constant-false marker (blob e4365cd to b4195746). Tests 1 failed (the empty-body note test), 12 passed. Restored the same way. A first attempt whose replacement was a substring of the anchor was refused by the tool (replacement count did not rise) and ran nothing. - Ablations A and B ran on c2a2ffc; the later commit does not touch those lines. - `pnpm --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`): exit 0. </details> ## Acceptance notes - Acceptance "the copied Create/Update example carries no reference field the author must fill by hand unless required, never owner_id": met by construction and pinned. `owner_id` is `system: true` and never required, so it is never in the body. - Acceptance "the pin proves the body is accepted": the pin asserts the body leaves the write path's reference check nothing to resolve and that each value parses under the stored-form contract. It does not POST to a live server. The card's own reproduction step 5 (the same bodies without the reference keys answer 201 and 200) is the live reading; it was not re-run here. - Media fields (`FILE_REFERENCE_TYPES`) keep the `file_id` sample. The write path's reference check does not resolve them (`referenceTargetOf` answers no target), so they are outside this card. ## Verification Local gates on head 8e49824: the `dispatch-gates --commands` list for this diff, 34 families, each recorded as `command :: exit code` before any pipe. All 34 exit 0; `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `node scripts/check-sdui-manifest.mjs` exit 0 (in that list). `pnpm --dir ui check:new-line-citations`: 0 new citations. `check-nul-bytes` exit 0. On the first head (c2a2ffc) `pnpm --dir ui exec vitest run scripts/` was red on two tests outside this diff (`check-hand-rolled-comment-mask.test.ts` and `js-comment-mask-returns-interpolation-9424.test.ts`), both `Test timed out in 15000ms` while a build held the box; on 8e49824 the same command exits 0. Not run locally, left to CI: the full `app-shell` suite (the `studio-design` folder alone holds 247 test files, past the container's foreground limit) and the repo-wide lint. CI runs the full set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CbUYNdYFJJSovGy2qL --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbUYNdYFJJSovGy2qL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea684a0 commit 178b523

3 files changed

Lines changed: 141 additions & 20 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Studio API tab's example body leaves out `owner_id` and optional reference fields, so the copied Create and Update calls are accepted
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The API tab of an object in Studio showed an example body that put the id `"related_record_id"` into every reference field, and added the platform's `owner_id` whenever there was room. That id names no record, so the copied Create (`POST`) and Update (`PATCH`) cURL were refused `400` with `reference_not_found` on nearly every object.
11+
12+
- **Left out of the body:** every optional reference field (`lookup`, `master_detail`, `user`, `tree`) and every field the platform adds (`system: true`, such as `owner_id`, which the platform fills in when it is left out). The note under the body names them with the other optional fields not shown.
13+
- **A required reference** is still in the body, because a create without it is refused. Its value is now a placeholder in the same form as the cURL's `<token>`, naming the object whose record id goes there, such as `<id of a showcase_project record>`.
14+
- **Unchanged:** the other fields, their sample values, the order (required fields first) and the cap of eight fields.

‎ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx‎

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ import '@testing-library/jest-dom/vitest';
3131
import * as React from 'react';
3232
import { afterEach, describe, expect, it, vi } from 'vitest';
3333
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
34-
import { valueSchemaFor, type ValueShapeFieldDef } from '@objectstack/spec/data';
34+
import {
35+
REFERENCE_VALUE_TYPES,
36+
referenceTargetOf,
37+
valueSchemaFor,
38+
type ValueShapeFieldDef,
39+
} from '@objectstack/spec/data';
3540
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
3641

3742
import { ObjectApiPanel } from './ObjectApiPanel';
@@ -196,15 +201,20 @@ describe('ObjectApiPanel example body: values the write path accepts (objectui#1
196201
expect(body, name).not.toHaveProperty(name);
197202
continue;
198203
}
199-
expect(body, name).toHaveProperty(name);
200-
const parsed = storedParse(def, body[name]);
201-
expect(parsed.success, `${name} (${String(def.type)}) = ${JSON.stringify(body[name])}`).toBe(true);
204+
// An optional reference never joins the body (objectstack#27490); its
205+
// value shape is read with the field required.
206+
const shownDef = REFERENCE_VALUE_TYPES.has(String(def.type)) ? { ...def, required: true } : def;
207+
const value = shownDef === def ? body[name] : shownBody({ [name]: shownDef })[name];
208+
cleanup();
209+
expect(value, name).toBeDefined();
210+
const parsed = storedParse(shownDef, value);
211+
expect(parsed.success, `${name} (${String(def.type)}) = ${JSON.stringify(value)}`).toBe(true);
202212
}
203213
});
204214

205215
it('a multi-valued field shows an array of one element', () => {
206216
const body = shownBody({
207-
f_lookups: ZOO_FIELDS.f_lookups,
217+
f_lookups: { ...ZOO_FIELDS.f_lookups, required: true },
208218
f_multiselect: ZOO_FIELDS.f_multiselect,
209219
f_tags: ZOO_FIELDS.f_tags,
210220
f_files: { type: 'file', label: 'Files', multiple: true },
@@ -289,3 +299,80 @@ describe('ObjectApiPanel copied cURL (objectui#11803)', () => {
289299
expect(copied).toContain(`-d '${JSON.stringify(body)}'`);
290300
});
291301
});
302+
303+
/**
304+
* The body the write path accepts as it stands — objectstack#27490.
305+
*
306+
* The defect: every reference field showed the id `"related_record_id"`, which
307+
* names no record, and the platform-added `owner_id` joined the body whenever
308+
* there was room under the cap, so the copied Create and Update calls were
309+
* refused `reference_not_found` on nearly every object.
310+
*
311+
* The oracle is the write path's own reference check (`assertReferencesResolve`
312+
* in `@objectstack/objectql`): it resolves a supplied value only on a field
313+
* `referenceTargetOf` answers a target for, so a body in which no field has
314+
* one leaves that check nothing to refuse, and every value still parses under
315+
* the stored-form contract.
316+
*/
317+
describe('ObjectApiPanel example body: no record id to look up (objectstack#27490)', () => {
318+
function expectAcceptedAsItStands(fields: Record<string, Def>) {
319+
const body = shownBody(fields);
320+
for (const [name, value] of Object.entries(body)) {
321+
const def = fields[name];
322+
expect(referenceTargetOf(def), `${name} names a record`).toBeUndefined();
323+
expect(storedParse(def, value).success, `${name} = ${JSON.stringify(value)}`).toBe(true);
324+
}
325+
return body;
326+
}
327+
328+
it('leaves owner_id out even when the body has room for it, and says so', () => {
329+
const body = expectAcceptedAsItStands(served({ name: { type: 'text', label: 'Name' } }));
330+
expect(body).toEqual({ name: 'string' });
331+
expect(screen.getByText(/owner_id/)).toBeInTheDocument();
332+
});
333+
334+
it('still names the fields it left out when none of them is shown', () => {
335+
const body = shownBody(served({ account: { type: 'lookup', label: 'Account', reference: 'showcase_account' } }));
336+
expect(body).toEqual({});
337+
expect(screen.getByText(/Optional fields not shown: owner_id, account/)).toBeInTheDocument();
338+
});
339+
340+
it('leaves every optional reference out (showcase Account and field zoo)', () => {
341+
expectAcceptedAsItStands(served(ACCOUNT_FIELDS));
342+
cleanup();
343+
const zoo = Object.fromEntries(Object.entries(ZOO_FIELDS).filter(([n]) => n !== 'f_master_detail'));
344+
const body = expectAcceptedAsItStands(served(zoo));
345+
expect(Object.keys(body)).toContain('name');
346+
const omitted = screen.getByText(/Optional fields not shown/).textContent ?? '';
347+
for (const ref of ['owner_id', 'f_lookup', 'f_lookups', 'f_tree', 'f_user', 'f_users']) {
348+
expect(omitted, ref).toContain(ref);
349+
}
350+
});
351+
352+
it('shows a required reference as a placeholder naming its target, never as an id', () => {
353+
const body = shownBody(served(ZOO_FIELDS));
354+
expect(body.f_master_detail).toBe('<id of a showcase_project record>');
355+
cleanup();
356+
const user = shownBody({ assignee: { type: 'user', label: 'Assignee', required: true } });
357+
expect(user.assignee).toBe('<id of a sys_user record>');
358+
});
359+
360+
it('the copied Create and Update calls carry the same body, without owner_id', async () => {
361+
const writeText = vi.fn(async (_text: string) => {});
362+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
363+
const body = expectAcceptedAsItStands(served(ACCOUNT_FIELDS));
364+
const rows = screen.getAllByTitle('Copy as cURL');
365+
// Endpoint rows: list, create (POST), get, update (PATCH), delete.
366+
fireEvent.click(rows[1]);
367+
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
368+
fireEvent.click(rows[3]);
369+
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2));
370+
const [create, update] = writeText.mock.calls.map(([text]) => text);
371+
expect(create).toContain('-X POST');
372+
expect(update).toContain('-X PATCH');
373+
for (const copied of [create, update]) {
374+
expect(copied).toContain(`-d '${JSON.stringify(body)}'`);
375+
expect(copied).not.toContain('owner_id');
376+
}
377+
});
378+
});

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
* cross-cutting concern that belongs in a global Developer console, not on a
2121
* per-object tab. This panel derives its example body from the object's DRAFT
2222
* fields, so it stays in sync with unpublished schema edits: each value is one
23-
* the write path accepts for that field's type (objectui#11803), and the
24-
* copied cURL carries the `Authorization` header the call needs.
23+
* the write path accepts for that field's type (objectui#11803), the body names
24+
* no record the reader would have to look up unless a required field needs one,
25+
* and the copied cURL carries the `Authorization` header the call needs.
2526
*/
2627

2728
import React from 'react';
@@ -39,6 +40,7 @@ import {
3940
SINGLE_OPTION_TYPES,
4041
STRING_VALUE_TYPES,
4142
isMultiValueField,
43+
referenceTargetOf,
4244
type FieldType,
4345
} from '@objectstack/spec/data';
4446
import type { ValueDomain } from '@objectstack/spec/shared';
@@ -154,8 +156,12 @@ function exampleElementFor(type: string, def: FieldDef): unknown {
154156
return firstOptionCode(def) ?? TEXT_SAMPLE;
155157
}
156158
// A reference stores the related record's id; a media field the id of an
157-
// uploaded file (`sys_file`), never an inline object or a URL.
158-
if (REFERENCE_VALUE_TYPES.has(type)) return 'related_record_id';
159+
// uploaded file (`sys_file`), never an inline object or a URL. The write path
160+
// refuses an id that names no record of the target, so a reference sample
161+
// cannot be a working value: it is a placeholder no reader takes for one,
162+
// naming the object whose id goes there. Only a required reference reaches
163+
// the body at all (`buildExampleBody`).
164+
if (REFERENCE_VALUE_TYPES.has(type)) return referencePlaceholderFor(def);
159165
if (FILE_REFERENCE_TYPES.has(type)) return 'file_id';
160166
switch (type) {
161167
case 'location':
@@ -175,6 +181,12 @@ function exampleElementFor(type: string, def: FieldDef): unknown {
175181
}
176182
}
177183

184+
/** The placeholder a required reference field shows, in the cURL token's `<...>` form. */
185+
function referencePlaceholderFor(def: FieldDef): string {
186+
const target = referenceTargetOf(def);
187+
return target ? `<id of a ${target} record>` : '<id of the related record>';
188+
}
189+
178190
/** The example value for one field: an array of one element when the field is multi-valued. */
179191
function exampleValueFor(def: FieldDef): unknown {
180192
const type = String(def.type);
@@ -185,27 +197,35 @@ function exampleValueFor(def: FieldDef): unknown {
185197
/**
186198
* An example create body from the object's writable draft fields, in the order
187199
* an author needs them: every required field first, then optional fields up to
188-
* {@link EXAMPLE_FIELD_CAP} — the object's own fields before the ones the
189-
* platform adds (`system: true`, such as `owner_id`, which the platform fills
190-
* in when it is left out). Computed, read-only and hidden fields are skipped.
191-
* `omitted` names the optional fields left out, so the panel can say so.
200+
* {@link EXAMPLE_FIELD_CAP}. Computed, read-only and hidden fields are skipped.
201+
*
202+
* Two kinds of optional field never join the body, so a copied call with no
203+
* required reference is accepted as it stands:
204+
* - a field the platform adds (`system: true`, such as `owner_id`), which the
205+
* platform fills in when it is left out;
206+
* - a reference field, whose value must be the id of an existing record of its
207+
* target — no sample can be one, and the write path refuses an id that
208+
* names no record.
209+
* `omitted` names every optional field left out, these included, so the panel
210+
* can say so.
192211
*/
193212
function buildExampleBody(fields: unknown): { body: Record<string, unknown>; omitted: string[] } {
194213
const writable = readFields(fields).entries.filter(({ def }) => {
195214
if (COMPUTED_VALUE_TYPES.has(String(def.type))) return false;
196215
return def.readonly !== true && def.hidden !== true;
197216
});
198217
const required = writable.filter(({ def }) => def.required === true);
199-
const optional = [
200-
...writable.filter(({ def }) => def.required !== true && def.system !== true),
201-
...writable.filter(({ def }) => def.required !== true && def.system === true),
202-
];
218+
const optional = writable.filter(({ def }) => def.required !== true);
219+
const neverShown = ({ def }: (typeof optional)[number]) =>
220+
def.system === true || REFERENCE_VALUE_TYPES.has(String(def.type));
221+
const showable = optional.filter((entry) => !neverShown(entry));
203222
const room = Math.max(0, EXAMPLE_FIELD_CAP - required.length);
204-
const shown = [...required, ...optional.slice(0, room)];
223+
const shown = [...required, ...showable.slice(0, room)];
205224
// `Object.fromEntries` defines own properties, so a field named `__proto__`
206225
// (a legal field name) is kept rather than swallowed by assignment.
207226
const body = Object.fromEntries(shown.map(({ name, def }) => [name, exampleValueFor(def)]));
208-
return { body, omitted: optional.slice(room).map(({ name }) => name) };
227+
const omitted = [...showable.slice(room), ...optional.filter(neverShown)];
228+
return { body, omitted: omitted.map(({ name }) => name) };
209229
}
210230

211231
export function ObjectApiPanel({
@@ -273,7 +293,7 @@ export function ObjectApiPanel({
273293
))}
274294
</div>
275295

276-
{Object.keys(exampleBody).length > 0 && (
296+
{(Object.keys(exampleBody).length > 0 || omitted.length > 0) && (
277297
<div className="rounded-lg border">
278298
<header className="border-b px-3 py-1.5 text-[11px] font-medium text-muted-foreground">
279299
{t('engine.studio.api.body', locale)}

0 commit comments

Comments
 (0)