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..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 @@ -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,80 @@ 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('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(); + 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..e4365cdd5e3 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({ @@ -273,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)}