Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/27490-studio-api-example-body-references.md
Original file line number Diff line number Diff line change
@@ -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 `<token>`, naming the object whose record id goes there, such as `<id of a showcase_project record>`.
- **Unchanged:** the other fields, their sample values, the order (required fields first) and the cap of eight fields.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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<string, Def>) {
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('<id of a showcase_project record>');
cleanup();
const user = shownBody({ assignee: { type: 'user', label: 'Assignee', required: true } });
expect(user.assignee).toBe('<id of a sys_user record>');
});

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');
}
});
});
50 changes: 35 additions & 15 deletions ui/packages/app-shell/src/views/studio-design/ObjectApiPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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':
Expand All @@ -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 ? `<id of a ${target} record>` : '<id of the related record>';
}

/** 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);
Expand All @@ -185,27 +197,35 @@ 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<string, unknown>; omitted: string[] } {
const writable = readFields(fields).entries.filter(({ def }) => {
if (COMPUTED_VALUE_TYPES.has(String(def.type))) return false;
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({
Expand Down Expand Up @@ -273,7 +293,7 @@ export function ObjectApiPanel({
))}
</div>

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