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
22 changes: 22 additions & 0 deletions .changeset/11569-line-items-child-object-optional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/plugin-form': minor
---

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.

**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
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.
124 changes: 124 additions & 0 deletions apps/console/src/__tests__/line-items-bound-child-object-11569.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
4 changes: 3 additions & 1 deletion content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
11 changes: 5 additions & 6 deletions packages/plugin-form/src/LineItemsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
13 changes: 12 additions & 1 deletion packages/plugin-form/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading