From c2a2ffce907e5da13a19c5490b0de19647690721 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:20:23 +0000 Subject: [PATCH 1/2] fix(app-shell): Studio API tab example body leaves out owner_id and optional references The example body put the id "related_record_id" into every reference field and added the platform-filled owner_id when there was room, so the copied Create and Update cURL were refused reference_not_found. Optional references and system fields now never join the body; a required reference shows a placeholder naming its target. Claude-Session: https://claude.ai/code/session_01Utp6CbUYNdYFJJSovGy2qL Co-authored-by: Claude --- ...7490-studio-api-example-body-references.md | 14 +++ .../ObjectApiPanel.exampleBody-11803.test.tsx | 91 ++++++++++++++++++- .../views/studio-design/ObjectApiPanel.tsx | 48 +++++++--- 3 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 .changeset/27490-studio-api-example-body-references.md diff --git a/.changeset/27490-studio-api-example-body-references.md b/.changeset/27490-studio-api-example-body-references.md new file mode 100644 index 00000000000..0a72b0ed072 --- /dev/null +++ b/.changeset/27490-studio-api-example-body-references.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +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. + +- **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. +- **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 ``, naming the object whose record id goes there, such as ``. +- **Unchanged:** the other fields, their sample values, the order (required fields first) and the cap of eight fields. diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx index f845a3d4a27..02f5776a2e3 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx @@ -31,7 +31,12 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { valueSchemaFor, type ValueShapeFieldDef } from '@objectstack/spec/data'; +import { + REFERENCE_VALUE_TYPES, + referenceTargetOf, + valueSchemaFor, + type ValueShapeFieldDef, +} from '@objectstack/spec/data'; import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared'; import { ObjectApiPanel } from './ObjectApiPanel'; @@ -196,15 +201,20 @@ describe('ObjectApiPanel example body: values the write path accepts (objectui#1 expect(body, name).not.toHaveProperty(name); continue; } - expect(body, name).toHaveProperty(name); - const parsed = storedParse(def, body[name]); - expect(parsed.success, `${name} (${String(def.type)}) = ${JSON.stringify(body[name])}`).toBe(true); + // An optional reference never joins the body (objectstack#27490); its + // value shape is read with the field required. + const shownDef = REFERENCE_VALUE_TYPES.has(String(def.type)) ? { ...def, required: true } : def; + const value = shownDef === def ? body[name] : shownBody({ [name]: shownDef })[name]; + cleanup(); + expect(value, name).toBeDefined(); + const parsed = storedParse(shownDef, value); + expect(parsed.success, `${name} (${String(def.type)}) = ${JSON.stringify(value)}`).toBe(true); } }); it('a multi-valued field shows an array of one element', () => { const body = shownBody({ - f_lookups: ZOO_FIELDS.f_lookups, + f_lookups: { ...ZOO_FIELDS.f_lookups, required: true }, f_multiselect: ZOO_FIELDS.f_multiselect, f_tags: ZOO_FIELDS.f_tags, f_files: { type: 'file', label: 'Files', multiple: true }, @@ -289,3 +299,74 @@ describe('ObjectApiPanel copied cURL (objectui#11803)', () => { expect(copied).toContain(`-d '${JSON.stringify(body)}'`); }); }); + +/** + * The body the write path accepts as it stands — objectstack#27490. + * + * The defect: every reference field showed the id `"related_record_id"`, which + * names no record, and the platform-added `owner_id` joined the body whenever + * there was room under the cap, so the copied Create and Update calls were + * refused `reference_not_found` on nearly every object. + * + * The oracle is the write path's own reference check (`assertReferencesResolve` + * in `@objectstack/objectql`): it resolves a supplied value only on a field + * `referenceTargetOf` answers a target for, so a body in which no field has + * one leaves that check nothing to refuse, and every value still parses under + * the stored-form contract. + */ +describe('ObjectApiPanel example body: no record id to look up (objectstack#27490)', () => { + function expectAcceptedAsItStands(fields: Record) { + const body = shownBody(fields); + for (const [name, value] of Object.entries(body)) { + const def = fields[name]; + expect(referenceTargetOf(def), `${name} names a record`).toBeUndefined(); + expect(storedParse(def, value).success, `${name} = ${JSON.stringify(value)}`).toBe(true); + } + return body; + } + + it('leaves owner_id out even when the body has room for it, and says so', () => { + const body = expectAcceptedAsItStands(served({ name: { type: 'text', label: 'Name' } })); + expect(body).toEqual({ name: 'string' }); + expect(screen.getByText(/owner_id/)).toBeInTheDocument(); + }); + + it('leaves every optional reference out (showcase Account and field zoo)', () => { + expectAcceptedAsItStands(served(ACCOUNT_FIELDS)); + cleanup(); + const zoo = Object.fromEntries(Object.entries(ZOO_FIELDS).filter(([n]) => n !== 'f_master_detail')); + const body = expectAcceptedAsItStands(served(zoo)); + expect(Object.keys(body)).toContain('name'); + const omitted = screen.getByText(/Optional fields not shown/).textContent ?? ''; + for (const ref of ['owner_id', 'f_lookup', 'f_lookups', 'f_tree', 'f_user', 'f_users']) { + expect(omitted, ref).toContain(ref); + } + }); + + it('shows a required reference as a placeholder naming its target, never as an id', () => { + const body = shownBody(served(ZOO_FIELDS)); + expect(body.f_master_detail).toBe(''); + cleanup(); + const user = shownBody({ assignee: { type: 'user', label: 'Assignee', required: true } }); + expect(user.assignee).toBe(''); + }); + + it('the copied Create and Update calls carry the same body, without owner_id', async () => { + const writeText = vi.fn(async (_text: string) => {}); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const body = expectAcceptedAsItStands(served(ACCOUNT_FIELDS)); + const rows = screen.getAllByTitle('Copy as cURL'); + // Endpoint rows: list, create (POST), get, update (PATCH), delete. + fireEvent.click(rows[1]); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + fireEvent.click(rows[3]); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2)); + const [create, update] = writeText.mock.calls.map(([text]) => text); + expect(create).toContain('-X POST'); + expect(update).toContain('-X PATCH'); + for (const copied of [create, update]) { + expect(copied).toContain(`-d '${JSON.stringify(body)}'`); + expect(copied).not.toContain('owner_id'); + } + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx index 0cb4c748c54..f6f17e678aa 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx @@ -20,8 +20,9 @@ * cross-cutting concern that belongs in a global Developer console, not on a * per-object tab. This panel derives its example body from the object's DRAFT * fields, so it stays in sync with unpublished schema edits: each value is one - * the write path accepts for that field's type (objectui#11803), and the - * copied cURL carries the `Authorization` header the call needs. + * the write path accepts for that field's type (objectui#11803), the body names + * no record the reader would have to look up unless a required field needs one, + * and the copied cURL carries the `Authorization` header the call needs. */ import React from 'react'; @@ -39,6 +40,7 @@ import { SINGLE_OPTION_TYPES, STRING_VALUE_TYPES, isMultiValueField, + referenceTargetOf, type FieldType, } from '@objectstack/spec/data'; import type { ValueDomain } from '@objectstack/spec/shared'; @@ -154,8 +156,12 @@ function exampleElementFor(type: string, def: FieldDef): unknown { return firstOptionCode(def) ?? TEXT_SAMPLE; } // A reference stores the related record's id; a media field the id of an - // uploaded file (`sys_file`), never an inline object or a URL. - if (REFERENCE_VALUE_TYPES.has(type)) return 'related_record_id'; + // uploaded file (`sys_file`), never an inline object or a URL. The write path + // refuses an id that names no record of the target, so a reference sample + // cannot be a working value: it is a placeholder no reader takes for one, + // naming the object whose id goes there. Only a required reference reaches + // the body at all (`buildExampleBody`). + if (REFERENCE_VALUE_TYPES.has(type)) return referencePlaceholderFor(def); if (FILE_REFERENCE_TYPES.has(type)) return 'file_id'; switch (type) { case 'location': @@ -175,6 +181,12 @@ function exampleElementFor(type: string, def: FieldDef): unknown { } } +/** The placeholder a required reference field shows, in the cURL token's `<...>` form. */ +function referencePlaceholderFor(def: FieldDef): string { + const target = referenceTargetOf(def); + return target ? `` : ''; +} + /** The example value for one field: an array of one element when the field is multi-valued. */ function exampleValueFor(def: FieldDef): unknown { const type = String(def.type); @@ -185,10 +197,17 @@ function exampleValueFor(def: FieldDef): unknown { /** * An example create body from the object's writable draft fields, in the order * an author needs them: every required field first, then optional fields up to - * {@link EXAMPLE_FIELD_CAP} — the object's own fields before the ones the - * platform adds (`system: true`, such as `owner_id`, which the platform fills - * in when it is left out). Computed, read-only and hidden fields are skipped. - * `omitted` names the optional fields left out, so the panel can say so. + * {@link EXAMPLE_FIELD_CAP}. Computed, read-only and hidden fields are skipped. + * + * Two kinds of optional field never join the body, so a copied call with no + * required reference is accepted as it stands: + * - a field the platform adds (`system: true`, such as `owner_id`), which the + * platform fills in when it is left out; + * - a reference field, whose value must be the id of an existing record of its + * target — no sample can be one, and the write path refuses an id that + * names no record. + * `omitted` names every optional field left out, these included, so the panel + * can say so. */ function buildExampleBody(fields: unknown): { body: Record; omitted: string[] } { const writable = readFields(fields).entries.filter(({ def }) => { @@ -196,16 +215,17 @@ function buildExampleBody(fields: unknown): { body: Record; omi return def.readonly !== true && def.hidden !== true; }); const required = writable.filter(({ def }) => def.required === true); - const optional = [ - ...writable.filter(({ def }) => def.required !== true && def.system !== true), - ...writable.filter(({ def }) => def.required !== true && def.system === true), - ]; + const optional = writable.filter(({ def }) => def.required !== true); + const neverShown = ({ def }: (typeof optional)[number]) => + def.system === true || REFERENCE_VALUE_TYPES.has(String(def.type)); + const showable = optional.filter((entry) => !neverShown(entry)); const room = Math.max(0, EXAMPLE_FIELD_CAP - required.length); - const shown = [...required, ...optional.slice(0, room)]; + const shown = [...required, ...showable.slice(0, room)]; // `Object.fromEntries` defines own properties, so a field named `__proto__` // (a legal field name) is kept rather than swallowed by assignment. const body = Object.fromEntries(shown.map(({ name, def }) => [name, exampleValueFor(def)])); - return { body, omitted: optional.slice(room).map(({ name }) => name) }; + const omitted = [...showable.slice(room), ...optional.filter(neverShown)]; + return { body, omitted: omitted.map(({ name }) => name) }; } export function ObjectApiPanel({ From 8e4982447c5350a899ed7739e57c0553b672657a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:09:11 +0000 Subject: [PATCH 2/2] fix(app-shell): keep the not-shown note when the example body is empty An object whose writable fields are all optional references or platform fields now shows an empty body, and the note still names them. Claude-Session: https://claude.ai/code/session_01Utp6CbUYNdYFJJSovGy2qL Co-authored-by: Claude --- .../studio-design/ObjectApiPanel.exampleBody-11803.test.tsx | 6 ++++++ .../app-shell/src/views/studio-design/ObjectApiPanel.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx index 02f5776a2e3..5b36c30125f 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.exampleBody-11803.test.tsx @@ -331,6 +331,12 @@ describe('ObjectApiPanel example body: no record id to look up (objectstack#2749 expect(screen.getByText(/owner_id/)).toBeInTheDocument(); }); + it('still names the fields it left out when none of them is shown', () => { + const body = shownBody(served({ account: { type: 'lookup', label: 'Account', reference: 'showcase_account' } })); + expect(body).toEqual({}); + expect(screen.getByText(/Optional fields not shown: owner_id, account/)).toBeInTheDocument(); + }); + it('leaves every optional reference out (showcase Account and field zoo)', () => { expectAcceptedAsItStands(served(ACCOUNT_FIELDS)); cleanup(); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx index f6f17e678aa..e4365cdd5e3 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx @@ -293,7 +293,7 @@ export function ObjectApiPanel({ ))} - {Object.keys(exampleBody).length > 0 && ( + {(Object.keys(exampleBody).length > 0 || omitted.length > 0) && (
{t('engine.studio.api.body', locale)}