From 448bac5da0780e8787cca5ee514dca17ff57482b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:06:57 +0000 Subject: [PATCH 1/2] fix(plugin-form): record:line_items stops requiring childObject, so the page compile accepts a dataSource-bound node (objectui#11569) The spec row leaves `childObject` optional because the node's `dataSource` binding can supply it, and `RECORD_LINE_ITEMS_DATA_SOURCE` lands `dataSource.object` on `childObject` before the panel reads the node. The registration still declared `required: true`, so the page compile refused a bound node with `missing-required-prop` at error severity. The manifest has no "one of these is required" form (`ComponentInput` and `ManifestInput` carry a boolean `required` only), so the input drops `required` and gains a description that names the binding. A node with neither keeps the panel's runtime configuration hint. New pin: apps/console line-items-bound-child-object-11569, judged against the shipped sdui.manifest.json. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../11569-line-items-child-object-optional.md | 22 ++++ ...ine-items-bound-child-object-11569.test.ts | 124 ++++++++++++++++++ content/docs/guide/data-source.md | 4 +- ...LineItemsPanel.childObjectDecline.test.tsx | 17 ++- packages/plugin-form/src/LineItemsPanel.tsx | 11 +- packages/plugin-form/src/index.tsx | 13 +- 6 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 .changeset/11569-line-items-child-object-optional.md create mode 100644 apps/console/src/__tests__/line-items-bound-child-object-11569.test.ts diff --git a/.changeset/11569-line-items-child-object-optional.md b/.changeset/11569-line-items-child-object-optional.md new file mode 100644 index 0000000000..9bd82927db --- /dev/null +++ b/.changeset/11569-line-items-child-object-optional.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-form': patch +--- + +The `record:line_items` registration no longer declares `childObject` required, +so the page compile accepts a node whose `dataSource` binding names the child +object (objectui#11569). + +`@objectstack/spec`'s `record:line_items` row leaves `childObject` optional, +because the node's `dataSource` binding can supply it, and the renderer agrees: +`dataSource.object` lands on `childObject` before the panel reads the node. The +registration still declared `required: true`, and the page compile reads the +registration, so a bound node with no `childObject` of its own was refused with +`missing-required-prop` and the save failed. + +**Behaviour change.** A `record:line_items` node that names its child object +through `dataSource.object` and sets no `childObject` now compiles and saves. A +node that names its child object in neither place also compiles now: the panel +shows its configuration hint naming `childObject` and loads nothing, as it did +before when such a node reached it. `relationshipField` and `columns` are still +required. The published `childObject` input now carries a description that says +the binding can supply it. diff --git a/apps/console/src/__tests__/line-items-bound-child-object-11569.test.ts b/apps/console/src/__tests__/line-items-bound-child-object-11569.test.ts new file mode 100644 index 0000000000..2705b2d774 --- /dev/null +++ b/apps/console/src/__tests__/line-items-bound-child-object-11569.test.ts @@ -0,0 +1,124 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11569 — the page compile accepts a `record:line_items` node whose + * `dataSource` binding names the child object, with no `childObject` of its own. + * + * `@objectstack/spec`'s `ComponentPropsMap['record:line_items']` makes + * `childObject` optional "because the component-level `dataSource` binding can + * supply the object instead", and the runtime agrees: the registration is + * gate-wrapped, and `RECORD_LINE_ITEMS_DATA_SOURCE` lands `dataSource.object` on + * `childObject` before `LineItemsPanel` reads the node. The registration still + * declared `{ name: 'childObject', required: true }`, and the page compile reads + * the registration, so it refused that node with `missing-required-prop` at + * error severity: `ok: false` at the save gate, for a node the spec row, the zod + * faces and the renderer all accept. + * + * The manifest has no vocabulary for "one of these is required" + * (`ComponentInput` in `@object-ui/types` and `ManifestInput` in + * `@object-ui/sdui-parser` carry a boolean `required` and nothing beside it), so + * the registration stops requiring the key (triage ruling on objectui#11569). + * + * Judged against the manifest the console SHIPS: `emitSduiManifest` over the + * registry `dev/manifest-registry.ts` loads, read back from the written + * `sdui.manifest.json`. That file is what a host registers as the page-save + * gate's manifest (objectui#11403), so these rows read the gate's own input, + * not a hand-built list. + * + * Rows: + * 1. The published entry declares `childObject`, not required, with a + * description that names the binding; `relationshipField` and `columns` + * stay required (the two members the row requires). + * 2. A node bound by `dataSource` with no `childObject` compiles `ok` with no + * diagnostic, and the binding is recorded as a site the server resolves. + * 3. Control: the same node naming `childObject` and no binding compiles `ok`. + * 4. Control: the gate still judges requiredness on this tag. A node naming + * `childObject` without `relationshipField` is refused with exactly one + * `missing-required-prop`, so the empty list in row 2 is a reading of THIS + * entry, not of an unresolved tag or an emptied input list. It names + * `childObject`, so it holds before and after the fix alike. + * 5. A node with neither `childObject` nor a binding draws no compile + * diagnostic about `childObject`: the ruling leaves that node to the runtime, + * whose configuration hint is pinned by `LineItemsPanel.childObjectDecline` + * in `@object-ui/plugin-form`. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { compile, type Manifest } from '@object-ui/sdui-parser'; +// Module scope, not a hook: the whole registration graph loads at import time. +import '../../dev/manifest-registry'; +import { emitSduiManifest } from '../../scripts/emit-sdui-manifest'; + +const scratchDir = mkdtempSync(join(tmpdir(), 'sdui-manifest-11569-')); +afterAll(() => { + rmSync(scratchDir, { recursive: true, force: true }); +}); + +/** The shipped `sdui.manifest.json`, read back as the host reads it. */ +const shipped = JSON.parse(readFileSync(emitSduiManifest(scratchDir), 'utf8')) as Manifest; + +const TAG = 'record:line_items'; +const COLUMNS = '{[{ name: "qty", label: "Qty", type: "number" }]}'; + +const lineItems = (props: string) => `<${TAG} ${props} columns=${COLUMNS} />`; + +const inputNamed = (name: string) => shipped.components[TAG]?.inputs.find((input) => input.name === name); + +const diagnosticsOf = (source: string) => + compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]); + +describe('objectui#11569 — record:line_items does not require childObject at the page compile', () => { + it('the published entry declares childObject as not required, and names the binding that can supply it', () => { + const childObject = inputNamed('childObject'); + expect(childObject, `${TAG} publishes no childObject input`).toBeDefined(); + expect(childObject?.required).not.toBe(true); + const description = childObject?.description ?? ''; + expect(description).toContain('`dataSource`'); + expect(description).toContain('`dataSource.object`'); + // The two members the spec row requires stay required. + expect(inputNamed('relationshipField')?.required).toBe(true); + expect(inputNamed('columns')?.required).toBe(true); + }); + + it('a dataSource-bound node with no childObject compiles ok, and the binding is a resolved site', () => { + const result = compile(lineItems('dataSource={{ object: "po_line" }} relationshipField="po"'), shipped); + expect(result.diagnostics.map((d) => [d.severity, d.code, d.message])).toEqual([]); + expect(result.ok).toBe(true); + expect(result.bindings).toEqual([ + { tag: TAG, input: 'dataSource', kind: 'object', value: { object: 'po_line' } }, + ]); + }); + + it('control: the same node naming childObject, with no binding, compiles ok', () => { + const result = compile(lineItems('childObject="po_line" relationshipField="po"'), shipped); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + }); + + it('control: the gate still judges requiredness on this tag — a missing relationshipField is refused', () => { + // Names `childObject`, so this row reads the same before and after the + // fix: it is a control on the gate, not a second pin. + const source = lineItems('childObject="po_line"'); + expect(diagnosticsOf(source)).toEqual([ + ['error', 'missing-required-prop', `<${TAG}> is missing required prop "relationshipField"`], + ]); + expect(compile(source, shipped).ok).toBe(false); + }); + + it('a node with neither childObject nor a binding draws no compile diagnostic about childObject', () => { + // The ruling leaves this node to the runtime: `LineItemsPanel` shows the + // configuration hint naming `childObject` and loads nothing, pinned by + // `LineItemsPanel.childObjectDecline.test.tsx` in `@object-ui/plugin-form`. + const messages = diagnosticsOf(lineItems('relationshipField="po"')).map(([, , message]) => message); + expect(messages.filter((message) => message.includes('"childObject"'))).toEqual([]); + }); +}); diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index f2dfd1f346..06befbfa97 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -280,7 +280,9 @@ Reading the `object` column: it lands on the block's own object key, which is lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part of the binding and stays the author's — it has to name a field on the bound child object, so rebinding `object` without updating it is an authoring error the panel -cannot paper over. +cannot paper over. A `record:line_items` node bound this way needs no `childObject` +of its own, and the page compile accepts it; a node that names its child object in +neither place shows a configuration hint naming `childObject` and loads nothing. Because the binding lands on `objectName`, a node bound this way needs no `objectName` of its own. The schema validator (`safeValidateSchema`, which diff --git a/packages/plugin-form/src/LineItemsPanel.childObjectDecline.test.tsx b/packages/plugin-form/src/LineItemsPanel.childObjectDecline.test.tsx index a1d2f88dd8..24e1a548c1 100644 --- a/packages/plugin-form/src/LineItemsPanel.childObjectDecline.test.tsx +++ b/packages/plugin-form/src/LineItemsPanel.childObjectDecline.test.tsx @@ -17,15 +17,14 @@ * file, which pin the thing that made closing the second site safe. * * Same defect, same key name and same package as objectui#5940, which fixed the - * sibling site in `MasterDetailForm`. `childObject` is declared - * `required: true` on the registry entry for `record:line_items` - * (`index.tsx`) and is typed `string` on `LineItemsPanelSchema`, but NOTHING - * enforces either: `inputs[].required` is designer metadata (WidgetRegistry - * copies it onto the ComponentRegistry entry and no one parses a node against - * it), and the block has no spec schema at all — `@objectstack/spec` names - * `record:line_items` only as an example of a type authored in the wild outside - * its union. So a node reaches this renderer straight off an authored schema - * with the key `undefined`, and the effect asked the data layer for it anyway. + * sibling site in `MasterDetailForm`. `childObject` is typed `string` on + * `LineItemsPanelSchema`, but the spec row and the registry entry for + * `record:line_items` (`index.tsx`) both leave it optional, because the node's + * `dataSource` binding can supply it (objectui#11569). So a node with neither + * reaches this renderer with the key `undefined`, and the effect asked the data + * layer for it anyway. That node is this file's answer: the page compile does + * not refuse it, and the configuration hint pinned below is what tells its + * author. * * ## Why the assertions read a CALL LIST * diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 57b239f970..37d28b38d6 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -304,12 +304,11 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch const ds: any = dataSource; if (!ds || typeof ds.getObjectSchema !== 'function') return; // Decline to fetch when the child object never resolved (objectui#6188). - // `childObject` is declared `required: true` on this block's registry entry - // and typed `string` above, but nothing enforces either — `inputs[].required` - // is designer metadata, and the block has no spec schema — so a node reaches - // this renderer straight off an authored schema with the key `undefined`, and - // the fetch below then asked the data layer for an object literally named - // `undefined`. `RelatedList` already takes the other choice for the same class + // `childObject` is typed `string` above, but the spec row and this block's + // registry entry both leave it optional, because the node's `dataSource` + // binding can supply it (objectui#11569) — so a node with neither reaches + // this renderer with the key `undefined`, and the fetch below then asked the + // data layer for an object literally named `undefined`. `RelatedList` already takes the other choice for the same class // of missing key ("has no referenceField/parentId — refusing to fetch all // rows", RelatedList.tsx), and `MasterDetailForm` declines on this exact key // (objectui#5940) with its child-schema cache spelling it `.filter(Boolean)`. diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 7ee8161365..e9b208727d 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -600,8 +600,19 @@ ComponentRegistry.register('line_items', LineItemsPanelRenderer, { // `LineItemsPanel` reads them itself; with one, the gate composes them with // the binding before the panel reads them. A key the panel does not read is // not declared here, and none of the row's fifteen is such a key. + // + // `childObject` is NOT required, as on the row (objectui#11569): the node's + // `dataSource` binding can supply it, and the page compile reads this list, + // so `required: true` here refused a bound node the row and the renderer + // accept. The manifest has no "one of these is required" form, so a node + // with neither is left to the panel's configuration hint. inputs: [ - { name: 'childObject', type: 'string', required: true }, + { + name: 'childObject', + type: 'string', + description: + '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.', + }, { name: 'relationshipField', type: 'string', required: true }, { name: 'columns', type: 'array', required: true }, { name: 'totalField', type: 'string' }, From 84ee3bec8d09b44bab9c3814694f7baebd51a36c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:27:38 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(changeset):=20grade=20the=20objectui#?= =?UTF-8?q?11569=20plugin-form=20changeset=20minor,=20with=20the=20Clause-?= =?UTF-8?q?=E2=91=A1=20widening=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR declares `Clause-②: yes`: the page compile's accept set widens for a `record:line_items` node bound through `dataSource` (and for one with neither). A `yes` declaration takes at least `minor`, as objectui#10909's `element:number` landing shipped. The widening paragraph now opens with the `**Clause-②: yes (widening)**` banner. Nothing else moves. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .changeset/11569-line-items-child-object-optional.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/11569-line-items-child-object-optional.md b/.changeset/11569-line-items-child-object-optional.md index 9bd82927db..a56ee25ec7 100644 --- a/.changeset/11569-line-items-child-object-optional.md +++ b/.changeset/11569-line-items-child-object-optional.md @@ -1,5 +1,5 @@ --- -'@object-ui/plugin-form': patch +'@object-ui/plugin-form': minor --- The `record:line_items` registration no longer declares `childObject` required, @@ -13,7 +13,7 @@ registration still declared `required: true`, and the page compile reads the registration, so a bound node with no `childObject` of its own was refused with `missing-required-prop` and the save failed. -**Behaviour change.** A `record:line_items` node that names its child object +**Clause-②: yes (widening)** — a `record:line_items` node that names its child object through `dataSource.object` and sets no `childObject` now compiles and saves. A node that names its child object in neither place also compiles now: the panel shows its configuration hint naming `childObject` and loads nothing, as it did