|
| 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 | +}); |
0 commit comments