Skip to content

Commit 902ebab

Browse files
fix(plugin-form): record:line_items stops requiring childObject, so the page compile accepts a dataSource-bound node (objectui#11569) (#11604)
Fixes #11569 Clause-②: yes ## What changes The `record:line_items` registration in `@object-ui/plugin-form` no longer declares `childObject` with `required: true`. The input now publishes a `description`: the node's `dataSource` binding can name the object instead (`dataSource.object` lands on this key and outranks an authored value), and a node with neither shows the panel's configuration hint and loads nothing. Why: `@objectstack/spec` 17.6.0's `ComponentPropsMap['record:line_items']` leaves `childObject` optional "because the component-level `dataSource` binding can supply the object instead", and the renderer agrees (`RECORD_LINE_ITEMS_DATA_SOURCE` maps the binding's `object` onto `childObject`). The page compile (`compile()` in `@object-ui/sdui-parser`, whose `ok` is the save gate) reads the registration, so it refused a bound node that the row, the zod faces and the runtime all accept. ## The vocabulary question, measured The manifest has no "one of these is required" form, so per the triage ruling `required: true` is dropped and nothing is added: - `ComponentInput` (`@object-ui/types`, `base.ts`) declares `name`, `type`, `of`, `required` (a boolean), `enum` and `description`, plus retirement tombstones. - `ManifestInput` (`@object-ui/sdui-parser`, `types.ts`) adds the framework-set `binding: 'object'` marker. It says an input NAMES an object and carries no requiredness. - `validateTree`'s only requiredness check is `input.required && !(input.name in node)`. `packages/types` is not edited. The same shape already landed in this repo: `element:number`'s `object` (objectui#10909), and the `objectName` inputs of `object-calendar`, `object-gantt`, `object-map` and `object-timeline`, each dropped `required` and gained a description that names the binding. ## Accept-set changes (for the contract review) Measured against the manifest the console ships (`buildArtifacts` over `dev/manifest-registry.ts`). "Before" is this head with `required: true` put back through `ablation-replace.mjs`; "after" is this head. | node | before | after | |---|---|---| | `record:line_items` with `dataSource.object`, `relationshipField`, `columns`, no `childObject` | `ok: false`: one error, `missing-required-prop` naming `childObject` | `ok: true`, no diagnostics. The `dataSource` binding site is recorded in both runs | | `record:line_items` with `relationshipField` and `columns`, and neither `childObject` nor `dataSource` | `ok: false`: the same error | `ok: true`, no diagnostics. The ruling leaves this node to the runtime: the panel shows the configuration hint naming `childObject` and fetches nothing. `LineItemsPanel.childObjectDecline.test.tsx` pins the hint, unchanged and green | | `record:line_items` with `childObject`, `relationshipField` and `columns` | `ok: true` | `ok: true` (control) | Generated artifacts that move with it (same generator, same runs): - `sdui.manifest.json`, entry `record:line_items`, input `childObject`: `required: true` removed, `description` added. - `sdui-intrinsics.d.ts` (the typed JSX face): `childObject: string` becomes `childObject?: string`. - `sdui-blocks.md`, the required-props column for `record:line_items`: `childObject, relationshipField, columns` becomes `relationshipField, columns`. Unchanged: `relationshipField` and `columns` stay required. The zod faces (`objectui validate`), the spec row and the renderer are not touched. ## registry-inputs-spec-parity `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` does not compare requiredness, so nothing in it records this difference and the file is unchanged. Evidence: the file never reads an input's `required`, and it does read `input.of` and `input.type` (the control). It is green on this head. ## Pins New: `apps/console/src/__tests__/line-items-bound-child-object-11569.test.ts`. It judges against the shipped `sdui.manifest.json` (written by `emitSduiManifest` and read back): 1. The published entry declares `childObject` as not required, with a description that names `dataSource` and `dataSource.object`. `relationshipField` and `columns` stay required. 2. The bound node compiles `ok` with no diagnostic, and the binding is recorded. 3. Control: the `childObject` node compiles `ok`. 4. Control: a node that names `childObject` but has no `relationshipField` is refused with exactly one `missing-required-prop`. This row reads the same before and after the fix. 5. The neither node draws no compile diagnostic about `childObject`. Reproduced first: before the fix, rows 1, 2 and 5 were red, each with `missing-required-prop` naming `childObject` at error severity. Ablation, run on the committed fix: `ablation-replace.mjs` put `required: true` back (anchor hit once, blob `e9b208727dd7` became `2591aef44b45`). Rows 1, 2 and 5 turned red (3 failed, 2 passed). The restore is proven by blob hash: `e9b208727dd7` equals HEAD and `git diff HEAD` is empty. On resolution: the console project aliases `@object-ui/plugin-form` to `packages/plugin-form/src`, so the mutation reaches the test without a build. One first attempt was refused by the tool before any test ran, because its replacement contained its own anchor. It restored to HEAD and measured nothing. ## Other edits - `LineItemsPanel.tsx` and `LineItemsPanel.childObjectDecline.test.tsx`: comment-only. Two comments said `childObject` was declared `required: true` on the registry entry and that the block had no spec schema. They now state the current facts. - `content/docs/guide/data-source.md`: one prose sentence, no code fence. A bound `record:line_items` node needs no `childObject` and the page compile accepts it. A node with neither shows the configuration hint. - `.changeset/11569-line-items-child-object-optional.md`: `@object-ui/plugin-form` `minor`. A `Clause-②: yes` widening takes at least `minor`, which matches the objectui#10909 landing (PR objectui#10944). It was first graded `patch` and regraded at `84ee3be` after the at-tier contract review `5978497549`. Its widening paragraph opens with the `**Clause-②: yes (widening)**` banner. ## Verification (head `84ee3be`) `84ee3be` changes only the changeset (the grade and the banner). The test, build and type-check runs below were taken at `448bac5`, whose code is identical. The changeset gates were re-run at `84ee3be`: `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-overwrite`, `check-changeset-fixed`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:control-bytes` and `check:new-line-citations` each exit 0. - `pnpm --filter '@object-ui/plugin-form^...' build`: exit 0 (12 of 47 projects). - `pnpm --filter @object-ui/plugin-form type-check`: exit 0 (`tsc --noEmit && tsc -p tsconfig.test.json`). - `pnpm exec vitest run packages/plugin-form/`: exit 0. `Test Files 163 passed (163)`, `Tests 1889 passed | 1 skipped (1890)`. - 21 files that read the registry manifest or the edited docs: exit 0, `Test Files 21 passed (21)`, `Tests 614 passed (614)`. They include the new pin, `registry-inputs-spec-parity`, `sdui-manifest-build-11403`, `html-tier-manifest`, `sdui-intrinsics-compile-11075`, `public-contract`, `record-block-record-reach`, `element-data-source-objectname-waiver-11117` (reads `data-source.md`) and `registered-types-validate-ratchet-10859`. - `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-overwrite`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (`0 new citation(s)`), `check:handler-key-reads`, `docs:check-links`, `check:doc-types`, `check:doc-fences`, `check:doc-example-ids`, `check:element-data-source-declaration`, `check:test-path-roots` and `check-vi-mock-override-shape`: each exit 0. - `check-governed-queue-guard --test` over the six paths: `NOT GOVERNED`. - eslint over the 4 touched ts/tsx files (`--no-inline-config --format json`): 4 files, 0 errors. The warning count per file is equal at base and head (22, 18, 4). The config has no type-aware linting (no `project` / `projectService`), and no custom rule in `eslint-rules/` reads the disk, so this diff cannot change the result for an untouched file. - NOT MEASURED, left to CI: - `check:doc-snippets` and `check:doc-examples` stopped at `PRECONDITION NOT MET` (exit 2) because the packages are unbuilt. The docs edit adds no code fence. - The full `@object-ui/console` type-check needs the whole console closure built. A scoped `tsc` over the new test file (console tsconfig) shows the file in the program with 0 errors in it. All 36 errors it printed are module-resolution errors (TS2307 / TS2882) for unbuilt packages, in `dev/manifest-registry.ts` and `src/register-plugins.ts`. ## Acceptance notes - `LineItemsPanelSchema.childObject` (the panel component's TypeScript prop type) is still `string`, not optional. It is a React prop type, not stored metadata: the gate fills it before the panel reads it, and the panel guards the `undefined` case. Recorded only. Carrier: none. - The same defect class on sibling gate-wrapped blocks is listed in the dev report for the seat to file. Not touched here. Implemented by the os-dev run under session `https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2`, for the claim on the card. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 278d244 commit 902ebab

6 files changed

Lines changed: 174 additions & 17 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/plugin-form': minor
3+
---
4+
5+
The `record:line_items` registration no longer declares `childObject` required,
6+
so the page compile accepts a node whose `dataSource` binding names the child
7+
object (objectui#11569).
8+
9+
`@objectstack/spec`'s `record:line_items` row leaves `childObject` optional,
10+
because the node's `dataSource` binding can supply it, and the renderer agrees:
11+
`dataSource.object` lands on `childObject` before the panel reads the node. The
12+
registration still declared `required: true`, and the page compile reads the
13+
registration, so a bound node with no `childObject` of its own was refused with
14+
`missing-required-prop` and the save failed.
15+
16+
**Clause-②: yes (widening)** — a `record:line_items` node that names its child object
17+
through `dataSource.object` and sets no `childObject` now compiles and saves. A
18+
node that names its child object in neither place also compiles now: the panel
19+
shows its configuration hint naming `childObject` and loads nothing, as it did
20+
before when such a node reached it. `relationshipField` and `columns` are still
21+
required. The published `childObject` input now carries a description that says
22+
the binding can supply it.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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#11569 — the page compile accepts a `record:line_items` node whose
11+
* `dataSource` binding names the child object, with no `childObject` of its own.
12+
*
13+
* `@objectstack/spec`'s `ComponentPropsMap['record:line_items']` makes
14+
* `childObject` optional "because the component-level `dataSource` binding can
15+
* supply the object instead", and the runtime agrees: the registration is
16+
* gate-wrapped, and `RECORD_LINE_ITEMS_DATA_SOURCE` lands `dataSource.object` on
17+
* `childObject` before `LineItemsPanel` reads the node. The registration still
18+
* declared `{ name: 'childObject', required: true }`, and the page compile reads
19+
* the registration, so it refused that node with `missing-required-prop` at
20+
* error severity: `ok: false` at the save gate, for a node the spec row, the zod
21+
* faces and the renderer all accept.
22+
*
23+
* The manifest has no vocabulary for "one of these is required"
24+
* (`ComponentInput` in `@object-ui/types` and `ManifestInput` in
25+
* `@object-ui/sdui-parser` carry a boolean `required` and nothing beside it), so
26+
* the registration stops requiring the key (triage ruling on objectui#11569).
27+
*
28+
* Judged against the manifest the console SHIPS: `emitSduiManifest` over the
29+
* registry `dev/manifest-registry.ts` loads, read back from the written
30+
* `sdui.manifest.json`. That file is what a host registers as the page-save
31+
* gate's manifest (objectui#11403), so these rows read the gate's own input,
32+
* not a hand-built list.
33+
*
34+
* Rows:
35+
* 1. The published entry declares `childObject`, not required, with a
36+
* description that names the binding; `relationshipField` and `columns`
37+
* stay required (the two members the row requires).
38+
* 2. A node bound by `dataSource` with no `childObject` compiles `ok` with no
39+
* diagnostic, and the binding is recorded as a site the server resolves.
40+
* 3. Control: the same node naming `childObject` and no binding compiles `ok`.
41+
* 4. Control: the gate still judges requiredness on this tag. A node naming
42+
* `childObject` without `relationshipField` is refused with exactly one
43+
* `missing-required-prop`, so the empty list in row 2 is a reading of THIS
44+
* entry, not of an unresolved tag or an emptied input list. It names
45+
* `childObject`, so it holds before and after the fix alike.
46+
* 5. A node with neither `childObject` nor a binding draws no compile
47+
* diagnostic about `childObject`: the ruling leaves that node to the runtime,
48+
* whose configuration hint is pinned by `LineItemsPanel.childObjectDecline`
49+
* in `@object-ui/plugin-form`.
50+
*/
51+
52+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
53+
import { tmpdir } from 'node:os';
54+
import { join } from 'node:path';
55+
import { afterAll, describe, expect, it } from 'vitest';
56+
import { compile, type Manifest } from '@object-ui/sdui-parser';
57+
// Module scope, not a hook: the whole registration graph loads at import time.
58+
import '../../dev/manifest-registry';
59+
import { emitSduiManifest } from '../../scripts/emit-sdui-manifest';
60+
61+
const scratchDir = mkdtempSync(join(tmpdir(), 'sdui-manifest-11569-'));
62+
afterAll(() => {
63+
rmSync(scratchDir, { recursive: true, force: true });
64+
});
65+
66+
/** The shipped `sdui.manifest.json`, read back as the host reads it. */
67+
const shipped = JSON.parse(readFileSync(emitSduiManifest(scratchDir), 'utf8')) as Manifest;
68+
69+
const TAG = 'record:line_items';
70+
const COLUMNS = '{[{ name: "qty", label: "Qty", type: "number" }]}';
71+
72+
const lineItems = (props: string) => `<${TAG} ${props} columns=${COLUMNS} />`;
73+
74+
const inputNamed = (name: string) => shipped.components[TAG]?.inputs.find((input) => input.name === name);
75+
76+
const diagnosticsOf = (source: string) =>
77+
compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]);
78+
79+
describe('objectui#11569 — record:line_items does not require childObject at the page compile', () => {
80+
it('the published entry declares childObject as not required, and names the binding that can supply it', () => {
81+
const childObject = inputNamed('childObject');
82+
expect(childObject, `${TAG} publishes no childObject input`).toBeDefined();
83+
expect(childObject?.required).not.toBe(true);
84+
const description = childObject?.description ?? '';
85+
expect(description).toContain('`dataSource`');
86+
expect(description).toContain('`dataSource.object`');
87+
// The two members the spec row requires stay required.
88+
expect(inputNamed('relationshipField')?.required).toBe(true);
89+
expect(inputNamed('columns')?.required).toBe(true);
90+
});
91+
92+
it('a dataSource-bound node with no childObject compiles ok, and the binding is a resolved site', () => {
93+
const result = compile(lineItems('dataSource={{ object: "po_line" }} relationshipField="po"'), shipped);
94+
expect(result.diagnostics.map((d) => [d.severity, d.code, d.message])).toEqual([]);
95+
expect(result.ok).toBe(true);
96+
expect(result.bindings).toEqual([
97+
{ tag: TAG, input: 'dataSource', kind: 'object', value: { object: 'po_line' } },
98+
]);
99+
});
100+
101+
it('control: the same node naming childObject, with no binding, compiles ok', () => {
102+
const result = compile(lineItems('childObject="po_line" relationshipField="po"'), shipped);
103+
expect(result.diagnostics).toEqual([]);
104+
expect(result.ok).toBe(true);
105+
});
106+
107+
it('control: the gate still judges requiredness on this tag — a missing relationshipField is refused', () => {
108+
// Names `childObject`, so this row reads the same before and after the
109+
// fix: it is a control on the gate, not a second pin.
110+
const source = lineItems('childObject="po_line"');
111+
expect(diagnosticsOf(source)).toEqual([
112+
['error', 'missing-required-prop', `<${TAG}> is missing required prop "relationshipField"`],
113+
]);
114+
expect(compile(source, shipped).ok).toBe(false);
115+
});
116+
117+
it('a node with neither childObject nor a binding draws no compile diagnostic about childObject', () => {
118+
// The ruling leaves this node to the runtime: `LineItemsPanel` shows the
119+
// configuration hint naming `childObject` and loads nothing, pinned by
120+
// `LineItemsPanel.childObjectDecline.test.tsx` in `@object-ui/plugin-form`.
121+
const messages = diagnosticsOf(lineItems('relationshipField="po"')).map(([, , message]) => message);
122+
expect(messages.filter((message) => message.includes('"childObject"'))).toEqual([]);
123+
});
124+
});

‎content/docs/guide/data-source.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ Reading the `object` column: it lands on the block's own object key, which is
280280
lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part
281281
of the binding and stays the author's — it has to name a field on the bound child
282282
object, so rebinding `object` without updating it is an authoring error the panel
283-
cannot paper over.
283+
cannot paper over. A `record:line_items` node bound this way needs no `childObject`
284+
of its own, and the page compile accepts it; a node that names its child object in
285+
neither place shows a configuration hint naming `childObject` and loads nothing.
284286

285287
Because the binding lands on `objectName`, a node bound this way needs no
286288
`objectName` of its own. The schema validator (`safeValidateSchema`, which

‎packages/plugin-form/src/LineItemsPanel.childObjectDecline.test.tsx‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,14 @@
1717
* file, which pin the thing that made closing the second site safe.
1818
*
1919
* Same defect, same key name and same package as objectui#5940, which fixed the
20-
* sibling site in `MasterDetailForm`. `childObject` is declared
21-
* `required: true` on the registry entry for `record:line_items`
22-
* (`index.tsx`) and is typed `string` on `LineItemsPanelSchema`, but NOTHING
23-
* enforces either: `inputs[].required` is designer metadata (WidgetRegistry
24-
* copies it onto the ComponentRegistry entry and no one parses a node against
25-
* it), and the block has no spec schema at all — `@objectstack/spec` names
26-
* `record:line_items` only as an example of a type authored in the wild outside
27-
* its union. So a node reaches this renderer straight off an authored schema
28-
* with the key `undefined`, and the effect asked the data layer for it anyway.
20+
* sibling site in `MasterDetailForm`. `childObject` is typed `string` on
21+
* `LineItemsPanelSchema`, but the spec row and the registry entry for
22+
* `record:line_items` (`index.tsx`) both leave it optional, because the node's
23+
* `dataSource` binding can supply it (objectui#11569). So a node with neither
24+
* reaches this renderer with the key `undefined`, and the effect asked the data
25+
* layer for it anyway. That node is this file's answer: the page compile does
26+
* not refuse it, and the configuration hint pinned below is what tells its
27+
* author.
2928
*
3029
* ## Why the assertions read a CALL LIST
3130
*

‎packages/plugin-form/src/LineItemsPanel.tsx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,11 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch
304304
const ds: any = dataSource;
305305
if (!ds || typeof ds.getObjectSchema !== 'function') return;
306306
// Decline to fetch when the child object never resolved (objectui#6188).
307-
// `childObject` is declared `required: true` on this block's registry entry
308-
// and typed `string` above, but nothing enforces either — `inputs[].required`
309-
// is designer metadata, and the block has no spec schema — so a node reaches
310-
// this renderer straight off an authored schema with the key `undefined`, and
311-
// the fetch below then asked the data layer for an object literally named
312-
// `undefined`. `RelatedList` already takes the other choice for the same class
307+
// `childObject` is typed `string` above, but the spec row and this block's
308+
// registry entry both leave it optional, because the node's `dataSource`
309+
// binding can supply it (objectui#11569) — so a node with neither reaches
310+
// this renderer with the key `undefined`, and the fetch below then asked the
311+
// data layer for an object literally named `undefined`. `RelatedList` already takes the other choice for the same class
313312
// of missing key ("has no referenceField/parentId — refusing to fetch all
314313
// rows", RelatedList.tsx), and `MasterDetailForm` declines on this exact key
315314
// (objectui#5940) with its child-schema cache spelling it `.filter(Boolean)`.

‎packages/plugin-form/src/index.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,19 @@ ComponentRegistry.register('line_items', LineItemsPanelRenderer, {
600600
// `LineItemsPanel` reads them itself; with one, the gate composes them with
601601
// the binding before the panel reads them. A key the panel does not read is
602602
// not declared here, and none of the row's fifteen is such a key.
603+
//
604+
// `childObject` is NOT required, as on the row (objectui#11569): the node's
605+
// `dataSource` binding can supply it, and the page compile reads this list,
606+
// so `required: true` here refused a bound node the row and the renderer
607+
// accept. The manifest has no "one of these is required" form, so a node
608+
// with neither is left to the panel's configuration hint.
603609
inputs: [
604-
{ name: 'childObject', type: 'string', required: true },
610+
{
611+
name: 'childObject',
612+
type: 'string',
613+
description:
614+
'The child object whose records the panel lists, edits and saves. Not required: the node\'s `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, the panel shows a configuration hint naming this key and loads nothing.',
615+
},
605616
{ name: 'relationshipField', type: 'string', required: true },
606617
{ name: 'columns', type: 'array', required: true },
607618
{ name: 'totalField', type: 'string' },

0 commit comments

Comments
 (0)