From 8e2c0190ec43e4f96af71cfd4b7e268279597d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:01:51 +0000 Subject: [PATCH 1/8] fix(plugins,react,i18n): bound registrations stop requiring objectName, and a node with neither shows a no-object hint (objectui#11605) The object-bound registrations whose spec row or binding doc waives `objectName` (object-grid, view:grid, list-view, view:list, object-form, view:form, embeddable-form, object-master-detail-form, object-kanban, object-metric, object-pivot, object-chart, view:chart) drop `required: true` and describe the binding, so the page compile accepts a node bound by `dataSource.object`. `ElementDataSourceGate` gains `requiresObject`, and each member whose neither-node answered with a blank opts in, so that node shows "No object named: set objectName or dataSource.object." (`view.noObject`, ten packs). Pins: the manifest enumeration pin and per-member compile rows, the neither-node hint rows, and the gate's own rows. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- .changeset/11605-i18n-view-no-object.md | 12 + ...11605-plugin-charts-objectname-optional.md | 24 ++ ...05-plugin-dashboard-objectname-optional.md | 27 ++ .../11605-plugin-form-objectname-optional.md | 29 ++ .../11605-plugin-grid-objectname-optional.md | 21 ++ ...11605-plugin-kanban-objectname-optional.md | 24 ++ .../11605-plugin-list-objectname-optional.md | 24 ++ .changeset/11605-react-requires-object.md | 24 ++ .../objectname-binding-required-11605.test.ts | 315 ++++++++++++++++++ .../objectname-neither-hint-11605.test.tsx | 183 ++++++++++ content/docs/guide/data-source.md | 11 + packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 5 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + packages/plugin-charts/src/ObjectChart.tsx | 31 +- packages/plugin-charts/src/index.tsx | 9 +- packages/plugin-dashboard/src/index.tsx | 39 ++- .../embeddable-form-arm-inputs-11440.test.ts | 15 +- .../__tests__/guideCrudAppRenders.test.tsx | 2 +- packages/plugin-form/src/index.tsx | 63 +++- .../__tests__/guideCrudAppRenders.test.tsx | 4 +- packages/plugin-grid/src/index.tsx | 14 +- packages/plugin-kanban/src/index.test.ts | 11 +- packages/plugin-kanban/src/index.tsx | 25 +- packages/plugin-list/src/ListViewBlock.tsx | 5 + packages/plugin-list/src/index.tsx | 27 +- packages/react/README.md | 9 + .../ElementDataSourceGate.tsx | 75 +++++ ...taSourceGate.requiresObject-11605.test.tsx | 102 ++++++ 36 files changed, 1111 insertions(+), 28 deletions(-) create mode 100644 .changeset/11605-i18n-view-no-object.md create mode 100644 .changeset/11605-plugin-charts-objectname-optional.md create mode 100644 .changeset/11605-plugin-dashboard-objectname-optional.md create mode 100644 .changeset/11605-plugin-form-objectname-optional.md create mode 100644 .changeset/11605-plugin-grid-objectname-optional.md create mode 100644 .changeset/11605-plugin-kanban-objectname-optional.md create mode 100644 .changeset/11605-plugin-list-objectname-optional.md create mode 100644 .changeset/11605-react-requires-object.md create mode 100644 apps/console/src/__tests__/objectname-binding-required-11605.test.ts create mode 100644 apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx create mode 100644 packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx diff --git a/.changeset/11605-i18n-view-no-object.md b/.changeset/11605-i18n-view-no-object.md new file mode 100644 index 0000000000..ea28b0f885 --- /dev/null +++ b/.changeset/11605-i18n-view-no-object.md @@ -0,0 +1,12 @@ +--- +'@object-ui/i18n': minor +--- + +All ten locale packs gain `view.noObject`, the hint an object-bound block shows +when its node names its object in neither place (objectui#11605). + +**Clause-②: yes (widening)** — a new key, `view.noObject`, in every pack. English +reads "No object named: set {{property}} or dataSource.object.", the wording of +`element.number.noObject` with the property as a hole; each translation is that +key's own translation with the same hole. `{{property}}` is the block's object +key, interpolated and never translated. No existing key changes. diff --git a/.changeset/11605-plugin-charts-objectname-optional.md b/.changeset/11605-plugin-charts-objectname-optional.md new file mode 100644 index 0000000000..77eb3c0afa --- /dev/null +++ b/.changeset/11605-plugin-charts-objectname-optional.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-charts': minor +--- + +The `object-chart` and `view:chart` registrations no longer declare `objectName` +required, so the page compile accepts a node whose `dataSource` binding names the +object, and a chart that names its object in neither place shows a hint instead +of an empty frame (objectui#11605). + +`object-chart` has no spec row. The binding doc says a node bound by +`dataSource.object` needs no `objectName` of its own, and the renderer agrees: +`dataSource.object` lands on `objectName` before the chart reads the node. The +registrations still declared `required: true`, and the page compile reads them, +so a bound chart with no `objectName` of its own was refused with +`missing-required-prop` and the save failed. + +**Clause-②: yes (widening)** — an `object-chart` (or `view:chart`) node that +names its object through `dataSource.object` and sets no `objectName` now +compiles and saves. A node that names its object in neither place also compiles +now, and the chart shows "No object named: set objectName or dataSource.object." +where it used to draw an empty frame with no message. A chart with inline +`data`, a `dataset` or a `bind` path shows no hint and renders as before. The +published `objectName` inputs now carry a description that says the binding can +supply them. diff --git a/.changeset/11605-plugin-dashboard-objectname-optional.md b/.changeset/11605-plugin-dashboard-objectname-optional.md new file mode 100644 index 0000000000..99b04d4df2 --- /dev/null +++ b/.changeset/11605-plugin-dashboard-objectname-optional.md @@ -0,0 +1,27 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +The `object-metric` and `object-pivot` registrations no longer declare +`objectName` required, so the page compile accepts a node whose `dataSource` +binding names the object, and a node that names its object in neither place +shows a hint instead of a value or an empty table (objectui#11605). + +`@objectstack/spec`'s `object-metric` row leaves `objectName` optional, because +the node's `dataSource` binding can supply it; `object-pivot` has no spec row, +and the binding doc says a bound node needs no `objectName` of its own. Both +renderers agree: `dataSource.object` lands on `objectName` before the block +reads the node. The registrations still declared `required: true`, and the page +compile reads them, so a bound node with no `objectName` of its own was refused +with `missing-required-prop` and the save failed. + +**Clause-②: yes (widening)** — an `object-metric` or `object-pivot` node that +names its object through `dataSource.object` and sets no `objectName` now +compiles and saves. A node that names its object in neither place also compiles +now, and shows "No object named: set objectName or dataSource.object." where the +metric used to draw a bare dash and the pivot an empty state saying its query +returned no records. A metric with an authored `fallbackValue`, and a pivot with +inline `data` or a `bind` path, show no hint and render as before. +`object-data-table` is unchanged: it reads no binding, so its `objectName` stays +required. The published `objectName` inputs now carry a description that says +the binding can supply them. diff --git a/.changeset/11605-plugin-form-objectname-optional.md b/.changeset/11605-plugin-form-objectname-optional.md new file mode 100644 index 0000000000..f1016b6b07 --- /dev/null +++ b/.changeset/11605-plugin-form-objectname-optional.md @@ -0,0 +1,29 @@ +--- +'@object-ui/plugin-form': minor +--- + +The `object-form`, `view:form`, `embeddable-form` and +`object-master-detail-form` registrations no longer declare `objectName` +required, so the page compile accepts a node whose `dataSource` binding names the +object, and a form that names its object in neither place shows a hint instead of +a form with no fields (objectui#11605). + +`@objectstack/spec`'s `object-form` and `object-master-detail-form` rows leave +`objectName` optional, because the node's `dataSource` binding can supply it; +`embeddable-form` has no spec row, and the binding doc says a bound node needs no +`objectName` of its own. Each renderer agrees: `dataSource.object` lands on +`objectName` before the form reads the node. The registrations still declared +`required: true`, and the page compile reads them, so a bound form with no +`objectName` of its own was refused with `missing-required-prop` and the save +failed. + +**Clause-②: yes (widening)** — an `object-form`, `view:form`, `embeddable-form` +or `object-master-detail-form` node that names its object through +`dataSource.object` and sets no `objectName` now compiles and saves. A node that +names its object in neither place also compiles now, and shows "No object named: +set objectName or dataSource.object." where it used to draw a field-less card, a +public form that could not submit, or an empty parent form. An `object-form` +with inline `customFields` shows no hint and renders as before. `formId` on +`embeddable-form` and `details` on `object-master-detail-form` are still +required. The published `objectName` inputs now carry a description that says the +binding can supply them. diff --git a/.changeset/11605-plugin-grid-objectname-optional.md b/.changeset/11605-plugin-grid-objectname-optional.md new file mode 100644 index 0000000000..2286715b68 --- /dev/null +++ b/.changeset/11605-plugin-grid-objectname-optional.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-grid': minor +--- + +The `object-grid` and `view:grid` registrations no longer declare `objectName` +required, so the page compile accepts a node whose `dataSource` binding names the +object (objectui#11605). + +`@objectstack/spec`'s `object-grid` row leaves `objectName` optional, because the +node's `dataSource` binding can supply it, and the renderer agrees: +`dataSource.object` lands on `objectName` before the grid reads the node. The +registration still declared `required: true`, and the page compile reads the +registration, so a bound node with no `objectName` of its own was refused with +`missing-required-prop` and the save failed. + +**Clause-②: yes (widening)** — an `object-grid` (or `view:grid`) node that names +its object through `dataSource.object` and sets no `objectName` now compiles and +saves. A node that names its object in neither place also compiles now; the grid +answers it at runtime with its own "Object name required for data fetching" +error, as it did before when such a node reached it. The published `objectName` +input now carries a description that says the binding can supply it. diff --git a/.changeset/11605-plugin-kanban-objectname-optional.md b/.changeset/11605-plugin-kanban-objectname-optional.md new file mode 100644 index 0000000000..16c4ae3fd2 --- /dev/null +++ b/.changeset/11605-plugin-kanban-objectname-optional.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-kanban': minor +--- + +The `object-kanban` registration no longer declares `objectName` required, so +the page compile accepts a board whose `dataSource` binding names the object, and +a board that names its object in neither place shows a hint instead of an empty +board (objectui#11605). + +`@objectstack/spec`'s `object-kanban` row leaves `objectName` optional, because +the node's `dataSource` binding can supply it, and the renderer agrees: +`dataSource.object` lands on `objectName` before the board reads the node. The +registration still declared `required: true`, and the page compile reads the +registration, so a bound board with no `objectName` of its own was refused with +`missing-required-prop` and the save failed. + +**Clause-②: yes (widening)** — an `object-kanban` node that names its object +through `dataSource.object` and sets no `objectName` now compiles and saves. A +node that names its object in neither place also compiles now, and the board +shows "No object named: set objectName or dataSource.object." where it used to +draw an empty board reading "No cards". A board with rows from inline `data` +(an empty array included), a `bind` path or a parent view shows no hint and +renders as before. The published `objectName` input now carries a description +that says the binding can supply it. diff --git a/.changeset/11605-plugin-list-objectname-optional.md b/.changeset/11605-plugin-list-objectname-optional.md new file mode 100644 index 0000000000..6834e706dd --- /dev/null +++ b/.changeset/11605-plugin-list-objectname-optional.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-list': minor +--- + +The `list-view` and `view:list` registrations no longer declare `objectName` +required, so the page compile accepts a node whose `dataSource` binding names the +object, and a list that names its object in neither place shows a hint instead of +an empty list (objectui#11605). + +`list-view` has no spec row. The binding doc says a node bound by +`dataSource.object` needs no `objectName` of its own, and the schema validator +counts the binding as a `list-view` record source. The renderer agrees: +`dataSource.object` lands on `objectName` before the list reads the node. The +registrations still declared `required: true`, and the page compile reads them, +so a bound list with no `objectName` of its own was refused with +`missing-required-prop` and the save failed. + +**Clause-②: yes (widening)** — a `list-view` (or `view:list`) node that names its +object through `dataSource.object` and sets no `objectName` now compiles and +saves. A node that names its object in neither place also compiles now, and the +list shows "No object named: set objectName or dataSource.object." where it used +to draw the "Nothing here yet" empty state. A list with inline `data` shows no +hint and renders as before. The published `objectName` input now carries a +description that says the binding can supply it. diff --git a/.changeset/11605-react-requires-object.md b/.changeset/11605-react-requires-object.md new file mode 100644 index 0000000000..1ab4b16b6a --- /dev/null +++ b/.changeset/11605-react-requires-object.md @@ -0,0 +1,24 @@ +--- +'@object-ui/react': minor +--- + +`ElementDataSourceGate` takes a `requiresObject` prop: when a placement opts in +and its node names its object in neither place, the gate renders a short "no +object named" hint instead of the block (objectui#11605). + +The object-bound registrations stopped declaring their object key required, +because the `dataSource` binding can supply it and the page compile has no "this +key or that binding" form. So the page compile accepts a node that names no +object at all, and the runtime's answer is the only signal left for it. Several +blocks answered such a node with an empty list, board, form, chart, dash or pivot, +which reads as an empty query. + +**Clause-②: yes (widening)** — `ElementDataSourceGateProps` gains the optional +`requiresObject` boolean. With it set, the gate reads the mapping's object key +(`objectName` unless the mapping names another) on the node after the binding +lands, so a node bound by `dataSource.object` renders as before; a node that names +no object gets the hint, "No object named: set objectName or dataSource.object." +(`data-testid` `{testId}-no-object`), and the block is not mounted. Without the +prop, or with a mapping whose `object` is `false`, nothing changes. The hint is +drawn after the view states, so a binding that is still resolving or failed to +resolve keeps its own panel. diff --git a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts new file mode 100644 index 0000000000..192c63a585 --- /dev/null +++ b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts @@ -0,0 +1,315 @@ +/** + * 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#11605 — a registration whose node accepts a `dataSource` binding + * does not declare `required: true` on a key that binding supplies, unless its + * spec row requires it. The family's closing card, after objectui#11569 + * (`record:line_items` and `childObject`). + * + * The page compile (`compile()` in `@object-ui/sdui-parser`, whose `ok` is the + * save gate) reads each registration's `inputs`, and its only requiredness + * check is `input.required && !(input.name in node)`. So a registration that + * declared `{ name: 'objectName', required: true }` refused a node whose + * `dataSource.object` names the object, although the gate lands that object + * on `objectName` before the block reads the node, and the spec row (or, where + * the block has none, the binding doc) says such a node needs no `objectName`. + * The manifest has no "this key or a binding that supplies it" form, and the + * triage ruling on this card forbids adding one, so the registrations stop + * requiring the key. A node with neither is answered at runtime by the gate's + * "no object named" hint, pinned beside this file in + * `objectname-neither-hint-11605.test.tsx`. + * + * Judged against the manifest the console SHIPS: `emitSduiManifest` over the + * registry `dev/manifest-registry.ts` loads, read back from the written + * `sdui.manifest.json`, the file a host registers as the page-save gate's + * manifest (objectui#11403). + * + * Rows: + * 1. The enumeration pin. For every entry whose inputs carry the injected + * `dataSource` binding input, every `required: true` input is one of: + * required by the tag's `ComponentPropsMap` row; not a key the binding + * supplies on that tag; or in {@link LEDGER} with a reason. One test per + * entry, so a newly registered contradiction is red BY NAME. + * 2. The ledger has no stale row: each names a bound entry that still requires + * that input, which the binding still supplies and the row does not require. + * 3. The binding doc's table names every bound entry, so what the binding + * supplies is read off the documented contract for each of them, and the + * object key this file assumes is one the entry declares. + * 4. Per member that moved: the published `objectName` is not required and its + * description names `dataSource.object`; a bound node compiles `ok` with no + * diagnostic and the binding is recorded; a node with neither draws no + * compile diagnostic about `objectName`. + * 5. Controls: the gate still judges requiredness on these tags (a moving + * member's other required input is still refused), and `record:related_list`, + * whose spec row requires `objectName`, still refuses a node without it. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { compile, type Manifest, type ManifestInput } 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 HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +const scratchDir = mkdtempSync(join(tmpdir(), 'sdui-manifest-11605-')); +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 inputsOf = (tag: string): ManifestInput[] => shipped.components[tag]?.inputs ?? []; + +/** + * The entries whose node accepts a `dataSource` binding: `Registry.register` + * injects the binding input (`binding: 'object'`) for exactly the renderers + * that wrap `ElementDataSourceGate`, so the manifest itself says which they are. + */ +const BOUND = Object.keys(shipped.components) + .filter((tag) => inputsOf(tag).some((input) => input.name === 'dataSource' && input.binding === 'object')) + .sort(); + +/* ── What the binding supplies, read off the binding doc ───────────────────── */ + +const BINDING_DOC = 'content/docs/guide/data-source.md'; + +const TABLE_HEADING = '### Which blocks consume it, and which keys each one honours'; + +/** + * The doc's "which keys each one honours" table, one row per block: + * `| \`tag\` | object | view | filter | sort | limit |`. Read from that heading + * to the end of the table only, so another table on the page cannot overwrite + * a row. + */ +function bindingDocRows(): Map { + const text = readFileSync(join(REPO_ROOT, BINDING_DOC), 'utf8'); + const start = text.indexOf(TABLE_HEADING); + expect(start, `${BINDING_DOC} has no "${TABLE_HEADING}" section`).toBeGreaterThan(-1); + const section = text.slice(start).split('\n'); + const tableStart = section.findIndex((line) => line.startsWith('|')); + const table: string[] = []; + for (const line of section.slice(tableStart)) { + if (!line.startsWith('|')) break; + table.push(line); + } + const rows = new Map(); + for (const line of table) { + const match = /^\| `([^`]+)` \| ([^|]+) \| ([^|]+) \|/.exec(line); + if (match) rows.set(match[1], { object: match[2].trim(), view: match[3].trim() }); + } + return rows; +} + +const DOC_ROWS = bindingDocRows(); + +/** + * The key the binding's `object` lands on. The doc: "it lands on the block's + * own object key, which is `objectName` everywhere except `record:line_items`" + * (its row spells that key in the `object` column), and "The two `element:*` + * rows keep their configuration in the node's `properties` bag ... + * `dataSource.object` wins over `properties.object`". + */ +function objectKeyOf(tag: string): string { + const named = /\(`([^`]+)`\)/.exec(DOC_ROWS.get(tag)?.object ?? ''); + if (named) return named[1]; + if (tag.startsWith('element:')) return 'object'; + return 'objectName'; +} + +/** + * The keys the binding supplies on `tag`: its object key always, and + * `columns` where a named view's field list fills them (the row's `view` + * column is a full ✅, or lists `columns`). `filter`, `sort` and the row cap + * are supplied too, but no registration requires one, so they are not read. + */ +function bindingSupplies(tag: string): Set { + const supplied = new Set([objectKeyOf(tag)]); + const view = DOC_ROWS.get(tag)?.view ?? ''; + if (view === '✅' || view.includes('columns')) supplied.add('columns'); + return supplied; +} + +/* ── What the spec row requires ────────────────────────────────────────────── */ + +type ZodMember = { isOptional: () => boolean }; +const specRowShape = (tag: string): Record | undefined => + (ComponentPropsMap as unknown as Record } | undefined>)[tag]?.shape; + +const specRowRequires = (tag: string, key: string): boolean => { + const member = specRowShape(tag)?.[key]; + return member !== undefined && !member.isOptional(); +}; + +/* ── The ledger ────────────────────────────────────────────────────────────── */ + +/** + * Required inputs that the binding supplies and the spec row does not require, + * each with the reason it stays required. Entries are debt, not acceptance: + * row 2 fails on one that no longer describes the manifest. + */ +const LEDGER: Readonly>>> = { + 'record:related_list': { + // Outside this card's `objectName` family, recorded rather than moved: the + // binding supplies `columns` only through a NAMED VIEW (the gate maps the + // view's field list onto them), never through its own `object`, so a node + // bound by `dataSource.object` alone still needs them. The spec row leaves + // `columns` optional; whether the registration should follow it is a + // separate question, reported on objectui#11605's dev report. + columns: + 'supplied only by a named view, not by `dataSource.object`; a node bound by object alone still needs its own columns (objectui#11605 dev report)', + }, +}; + +/* ── 1–3: the enumeration pin ──────────────────────────────────────────────── */ + +describe('objectui#11605 — a bound registration requires no input its binding supplies, unless its spec row does', () => { + it('the bound population is read from the shipped manifest and is not vacuous', () => { + // A control on the extraction, not a census: the members this card moves + // and the precedent's tag are all bound entries. + expect(BOUND).toEqual( + expect.arrayContaining([ + 'object-grid', + 'list-view', + 'object-form', + 'embeddable-form', + 'object-master-detail-form', + 'object-kanban', + 'object-metric', + 'object-chart', + 'object-pivot', + 'record:line_items', + 'record:related_list', + ]), + ); + // And the filter is a filter: `object-tree` is a shipped entry that wraps + // no gate, so it is not bound. + expect(shipped.components['object-tree']).toBeDefined(); + expect(BOUND).not.toContain('object-tree'); + }); + + for (const tag of BOUND) { + it(`${tag}: every required input is required by its spec row, not supplied by the binding, or ledgered`, () => { + const supplied = bindingSupplies(tag); + const contradictions = inputsOf(tag) + .filter((input) => input.required === true) + .map((input) => input.name) + .filter((name) => !specRowRequires(tag, name)) + .filter((name) => supplied.has(name)) + .filter((name) => LEDGER[tag]?.[name] === undefined); + expect( + contradictions, + `<${tag}> declares ${contradictions.map((n) => `"${n}"`).join(', ')} required, but its ` + + '`dataSource` binding supplies it and its spec row does not require it, so the page compile ' + + 'refuses a node bound by `dataSource.object`. Drop `required: true` and describe the binding ' + + '(objectui#11605), or ledger it here with the reason it stays.', + ).toEqual([]); + }); + } + + it('the ledger has no stale row', () => { + for (const [tag, rows] of Object.entries(LEDGER)) { + expect(BOUND, `ledgered tag ${tag} is not a bound entry of the shipped manifest`).toContain(tag); + for (const [name, reason] of Object.entries(rows)) { + const input = inputsOf(tag).find((candidate) => candidate.name === name); + expect(input?.required, `${tag}.${name} is ledgered but no longer required: delete the row`).toBe(true); + expect(bindingSupplies(tag).has(name), `${tag}.${name} is ledgered but the binding does not supply it`).toBe(true); + expect(specRowRequires(tag, name), `${tag}.${name} is ledgered but its spec row requires it`).toBe(false); + expect(reason.length, `${tag}.${name} needs a written reason`).toBeGreaterThan(20); + } + } + }); + + it('the binding doc names every bound entry, and each object key is one the entry declares', () => { + const undocumented = BOUND.filter((tag) => !DOC_ROWS.has(tag)); + expect(undocumented, `${BINDING_DOC} must say what the binding supplies on each bound block`).toEqual([]); + for (const tag of BOUND) { + const key = objectKeyOf(tag); + expect( + inputsOf(tag).map((input) => input.name), + `<${tag}>: the binding's object lands on "${key}", which the entry does not declare`, + ).toContain(key); + } + // Controls on the doc reading: the exception it names, and a full-view row. + expect(objectKeyOf('record:line_items')).toBe('childObject'); + expect(bindingSupplies('object-grid').has('columns')).toBe(true); + expect(bindingSupplies('object-kanban').has('columns')).toBe(false); + }); +}); + +/* ── 4–5: per member, the bound node and the neither node ──────────────────── */ + +/** + * Each member this card moved, with the other required props it still needs + * (as JSX attributes), so the bound node is a complete one. + */ +const MEMBERS: ReadonlyArray<{ tag: string; rest: string }> = [ + { tag: 'object-grid', rest: '' }, + { tag: 'list-view', rest: '' }, + { tag: 'object-form', rest: '' }, + { tag: 'embeddable-form', rest: 'formId="contact_us"' }, + { tag: 'object-master-detail-form', rest: 'details={[{ childObject: "order_line" }]}' }, + { tag: 'object-kanban', rest: '' }, + { tag: 'object-metric', rest: '' }, + { tag: 'object-chart', rest: '' }, + { tag: 'object-pivot', rest: 'rowField="region" columnField="stage" valueField="amount"' }, +]; + +const nodeOf = (tag: string, attrs: string) => `<${tag} ${attrs} />`; + +const diagnosticsOf = (source: string) => + compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]); + +describe('objectui#11605 — each member compiles a bound node, and leaves the neither node to the runtime', () => { + it.each(MEMBERS)('$tag — the published objectName is not required and names the binding', ({ tag }) => { + const objectName = inputsOf(tag).find((input) => input.name === 'objectName'); + expect(objectName, `${tag} publishes no objectName input`).toBeDefined(); + expect(objectName?.required).not.toBe(true); + expect(objectName?.description ?? '').toContain('`dataSource.object`'); + }); + + it.each(MEMBERS)('$tag — a dataSource-bound node with no objectName compiles ok', ({ tag, rest }) => { + const result = compile(nodeOf(tag, `dataSource={{ object: "account" }} ${rest}`), shipped); + expect(result.diagnostics.map((d) => [d.severity, d.code, d.message])).toEqual([]); + expect(result.ok).toBe(true); + expect(result.bindings).toEqual([ + { tag, input: 'dataSource', kind: 'object', value: { object: 'account' } }, + ]); + }); + + it.each(MEMBERS)('$tag — a node with neither draws no compile diagnostic about objectName', ({ tag, rest }) => { + const messages = diagnosticsOf(nodeOf(tag, rest)).map(([, , message]) => message); + expect(messages.filter((message) => String(message).includes('"objectName"'))).toEqual([]); + }); + + it('control: the gate still judges requiredness on a moved tag', () => { + // The bound node above names `formId`; without it the same tag is refused, + // so the empty list there is a reading of this entry. + const source = nodeOf('embeddable-form', 'dataSource={{ object: "account" }}'); + expect(diagnosticsOf(source)).toEqual([ + ['error', 'missing-required-prop', ' is missing required prop "formId"'], + ]); + expect(compile(source, shipped).ok).toBe(false); + }); + + it('control: record:related_list keeps objectName required, because its spec row requires it', () => { + expect(specRowRequires('record:related_list', 'objectName')).toBe(true); + const source = ''; + expect(diagnosticsOf(source)).toEqual([ + ['error', 'missing-required-prop', ' is missing required prop "objectName"'], + ]); + }); +}); diff --git a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx new file mode 100644 index 0000000000..a7ec81b0e1 --- /dev/null +++ b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx @@ -0,0 +1,183 @@ +/** + * 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#11605 — a node that names its object in neither place (no + * `objectName`, no `dataSource.object`) shows a "no object named" hint. + * + * The registrations these blocks publish stopped declaring `objectName` + * required (`objectname-binding-required-11605.test.ts`, beside this file), so + * the page compile accepts such a node, and the runtime's answer is now the + * only signal. Measured before this card, through `SchemaRenderer` under a + * provider with an adapter, each of these answered with a blank that reads as + * an empty query: `list-view` "Nothing here yet", `object-kanban` "No cards", + * `object-form` a field-less card, `embeddable-form` a form with no fields and + * a Submit button, `object-master-detail-form` an empty parent, `object-metric` + * a dash, `object-chart` an empty frame, `object-pivot` "its query returned no + * records yet". `object-grid` already answered with its own "Object name + * required for data fetching" error and is not opted in. + * + * Each member opts in through `ElementDataSourceGate`'s `requiresObject`, and + * the gate draws the hint after the binding is applied. Rows per member: + * 1. The neither node draws the hint, naming `objectName`, and asks the data + * layer for nothing. + * 2. Control: the same node with `objectName` draws no hint. + * 3. Control: the same node bound by `dataSource.object` draws no hint. + * 4. Control, where the block has another record source: the neither node with + * that source draws no hint, so the hint is not painted over a block that + * is working. + */ + +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// The graphs whose registrations this reads, at module scope: their cold +// transform is billed to the import phase (AGENTS.md test discipline). +import '@object-ui/components'; +import '../register-plugins'; + +/** Run the console's lazy loaders, so each tag mounts its real renderer. */ +async function resolveLazy(types: readonly string[]): Promise { + await Promise.all( + types.map((type) => { + const bare = type.includes(':') ? type.slice(type.indexOf(':') + 1) : type; + return ComponentRegistry.loadLazy(type) ?? ComponentRegistry.loadLazy(bare) ?? Promise.resolve(); + }), + ); +} + +const HINT = 'No object named: set objectName or dataSource.object.'; + +/** + * Each member: the tag, the gate's `testId` stem, the props a complete node + * carries beside its object, and the block's other record source where it has + * one (row 4). + */ +const MEMBERS: ReadonlyArray<{ + type: string; + testId: string; + base: Record; + otherSource?: Record; +}> = [ + { type: 'list-view', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } } }, + { type: 'view:list', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } } }, + { + type: 'object-form', + testId: 'object-form', + base: {}, + otherSource: { customFields: [{ name: 'title', label: 'Title', type: 'text' }] }, + }, + { + type: 'view:form', + testId: 'object-form', + base: {}, + otherSource: { customFields: [{ name: 'title', label: 'Title', type: 'text' }] }, + }, + { type: 'embeddable-form', testId: 'embeddable-form', base: { formId: 'contact_us' } }, + { + type: 'object-master-detail-form', + testId: 'object-master-detail-form', + base: { details: [{ childObject: 'order_line', title: 'Lines' }] }, + }, + { type: 'object-kanban', testId: 'object-kanban', base: { groupBy: 'stage' }, otherSource: { data: [] } }, + { type: 'object-metric', testId: 'object-metric', base: { label: 'Open deals' }, otherSource: { fallbackValue: '42' } }, + { + type: 'object-chart', + testId: 'object-chart', + base: { chartType: 'bar' }, + otherSource: { data: [{ name: 'Jan', value: 1 }] }, + }, + { + type: 'view:chart', + testId: 'object-chart', + base: { chartType: 'bar' }, + otherSource: { data: [{ name: 'Jan', value: 1 }] }, + }, + { + type: 'object-pivot', + testId: 'object-pivot', + base: { rowField: 'region', columnField: 'stage', valueField: 'amount' }, + otherSource: { data: [{ region: 'EU', stage: 'won', amount: 1 }] }, + }, +]; + +await resolveLazy(MEMBERS.map((member) => member.type)); + +/** A data source that records every call and answers each with nothing. */ +function recordingAdapter(): { adapter: unknown; calls: string[] } { + const calls: string[] = []; + const record = (key: string) => (...args: unknown[]) => { + calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`); + return /^on[A-Z]/.test(key) || key === 'subscribe' ? () => {} : Promise.resolve([]); + }; + const seeded: Record = {}; + for (const method of ['find', 'findOne', 'create', 'update', 'delete', 'aggregate', 'count', 'getObjectSchema', 'getObjects', 'getView', 'listViews', 'onMutation']) { + seeded[method] = record(method); + } + const adapter = new Proxy(seeded, { + get: (target, key: string) => (key in target ? target[key] : record(key)), + }); + return { adapter, calls }; +} + +async function mount(schema: Record) { + const { adapter, calls } = recordingAdapter(); + const view = render( + + + , + ); + // Settle: a block may fetch from an effect, after a lazy renderer resolves. + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + }); + } + const html = view.container.innerHTML; + const text = view.container.textContent ?? ''; + try { + view.unmount(); + } catch { + /* teardown is not the subject */ + } + return { html, text, calls, hasHint: (testId: string) => html.includes(`data-testid="${testId}-no-object"`) }; +} + +describe('objectui#11605 — a node naming its object in neither place shows the no-object hint', () => { + it.each(MEMBERS)('$type — the neither node draws the hint and fetches nothing', async ({ type, testId, base }) => { + const r = await mount({ type, ...base }); + expect(r.html, `<${type}> crashed instead of answering`).not.toContain('failed to render'); + expect(r.hasHint(testId), `<${type}> drew no no-object hint:\n${r.text.slice(0, 300)}`).toBe(true); + expect(r.text).toContain(HINT); + expect(r.calls).toEqual([]); + }); + + it.each(MEMBERS)('$type — control: with objectName, no hint', async ({ type, testId, base }) => { + const r = await mount({ type, ...base, objectName: 'account' }); + expect(r.html).not.toContain('failed to render'); + expect(r.hasHint(testId)).toBe(false); + expect(r.text).not.toContain(HINT); + }); + + it.each(MEMBERS)('$type — control: bound by dataSource.object, no hint', async ({ type, testId, base }) => { + const r = await mount({ type, ...base, dataSource: { object: 'account' } }); + expect(r.html).not.toContain('failed to render'); + expect(r.hasHint(testId)).toBe(false); + expect(r.text).not.toContain(HINT); + }); + + it.each(MEMBERS.filter((member) => member.otherSource))( + '$type — control: another record source, no hint', + async ({ type, testId, base, otherSource }) => { + const r = await mount({ type, ...base, ...otherSource }); + expect(r.html).not.toContain('failed to render'); + expect(r.hasHint(testId)).toBe(false); + }, + ); +}); diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index 06befbfa97..c82f0578a8 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -291,6 +291,17 @@ Because the binding lands on `objectName`, a node bound this way needs no and still refuses one of those nodes that declares no other record source and names its object in neither place. A binding with an empty `object` names nothing. +The page compile accepts such a node too, on every row above whose object lands on +`objectName`: those registrations do not declare `objectName` required, because the +binding can supply it. The exception is `record:related_list`, whose spec row +requires `objectName`, so the page compile still refuses one of those nodes without +it. A node that names its object in neither place renders a short hint, "No object +named: set objectName or dataSource.object.", in place of an empty list, board, +form, chart, metric or pivot. `object-grid` shows its own "Object name required for +data fetching" error instead. A block with another record source (inline `data` +rows, a form's `customFields`, a chart's `dataset`, a `bind` path, or a metric's +`fallbackValue`) draws from that source and shows no hint. + The two `element:*` rows keep their configuration in the node's `properties` bag, so the binding does not land on a schema key there: each reads it directly, and `dataSource.object` wins over `properties.object`. They differ on `filter`. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 1260e7fe37..f489838e2e 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1135,6 +1135,7 @@ const ar = { readonlyAriaLabel: "عرض للقراءة فقط", readonlyTooltip: "عرض النظام — معرَّف في الكود، للقراءة فقط.", malformedFilter: "عامل تصفية طريقة العرض هذه غير صالح، لذا لا تُعرض أي سجلات: يتعذّر تطبيق الشرط {{subject}}.", + noObject: "لم يُحدَّد أي كائن: عيّن {{property}} أو dataSource.object.", }, designer: { undo: "تراجع", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f9748f3663..a05211323a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1012,6 +1012,7 @@ const de = { readonlyAriaLabel: "Schreibgeschützte Ansicht", readonlyTooltip: "Systemansicht — im Code definiert, schreibgeschützt.", malformedFilter: "Der Filter dieser Ansicht ist fehlerhaft, daher werden keine Datensätze angezeigt: Die Bedingung {{subject}} kann nicht angewendet werden.", + noObject: "Kein Objekt angegeben: Legen Sie {{property}} oder dataSource.object fest.", }, designer: { undo: "Rückgängig", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 3419a6709c..52476ac343 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1248,6 +1248,11 @@ const en = { readonlyAriaLabel: 'Read-only view', readonlyTooltip: 'System view — defined in code, read-only.', malformedFilter: 'This view’s filter is malformed, so no records are shown: the {{subject}} condition cannot be applied.', + // objectui#11605 — an object-bound block whose node names its object in + // neither place (no own key, no `dataSource.object`). `{{property}}` is the + // block's object key (`objectName`), interpolated and never translated; the + // wording is `element.number.noObject`'s with the property as a hole. + noObject: 'No object named: set {{property}} or dataSource.object.', }, detail: { back: 'Back', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 65a01eef17..a0c4719be6 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1043,6 +1043,7 @@ const es = { readonlyAriaLabel: "Vista de solo lectura", readonlyTooltip: "Vista del sistema — definida en el código, solo lectura.", malformedFilter: "El filtro de esta vista no es válido, por lo que no se muestra ningún registro: la condición {{subject}} no se puede aplicar.", + noObject: "No se ha indicado ningún objeto: define {{property}} o dataSource.object.", }, designer: { undo: "Deshacer", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 16f572aa73..36dd8c2c74 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1039,6 +1039,7 @@ const fr = { readonlyAriaLabel: "Vue en lecture seule", readonlyTooltip: "Vue système — définie dans le code, en lecture seule.", malformedFilter: "Le filtre de cette vue est incorrect, aucun enregistrement n’est donc affiché : la condition {{subject}} ne peut pas être appliquée.", + noObject: "Aucun objet indiqué : définissez {{property}} ou dataSource.object.", }, designer: { undo: "Annuler", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 30654c24ae..fbc1594935 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1012,6 +1012,7 @@ const ja = { readonlyAriaLabel: "読み取り専用ビュー", readonlyTooltip: "システムビュー — コードで定義され、読み取り専用です。", malformedFilter: "このビューのフィルターが不正なため、レコードを表示しません:{{subject}} の条件を適用できません。", + noObject: "オブジェクトが指定されていません。{{property}} または dataSource.object を設定してください。", }, designer: { undo: "元に戻す", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index f6fd36d8c4..760f0166eb 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1012,6 +1012,7 @@ const ko = { readonlyAriaLabel: "읽기 전용 보기", readonlyTooltip: "시스템 보기 — 코드에 정의되어 있으며 읽기 전용입니다.", malformedFilter: "이 뷰의 필터가 잘못되어 레코드를 표시하지 않습니다: {{subject}} 조건을 적용할 수 없습니다.", + noObject: "개체가 지정되지 않았습니다. {{property}} 또는 dataSource.object를 설정하세요.", }, designer: { undo: "실행 취소", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 64e56b0dc4..69f6fcfcda 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1038,6 +1038,7 @@ const pt = { readonlyAriaLabel: "Exibição somente leitura", readonlyTooltip: "Exibição do sistema — definida no código, somente leitura.", malformedFilter: "O filtro desta exibição é inválido, por isso nenhum registro é exibido: a condição {{subject}} não pode ser aplicada.", + noObject: "Nenhum objeto indicado: defina {{property}} ou dataSource.object.", }, designer: { undo: "Desfazer", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 3cdd99b150..0498036bcc 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1077,6 +1077,7 @@ const ru = { readonlyAriaLabel: "Представление только для чтения", readonlyTooltip: "Системное представление — определено в коде, только для чтения.", malformedFilter: "Фильтр этого представления некорректен, поэтому записи не отображаются: условие {{subject}} невозможно применить.", + noObject: "Объект не указан: задайте {{property}} или dataSource.object.", }, designer: { undo: "Отменить", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 43eb3c5d24..6ab7151c21 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1037,6 +1037,7 @@ const zh = { readonlyAriaLabel: '只读视图', readonlyTooltip: '系统视图 — 由代码定义,只读。', malformedFilter: '此视图的筛选条件有误,因此不显示任何记录:无法应用 {{subject}} 条件。', + noObject: '未指定对象:请设置 {{property}} 或 dataSource.object。', }, designer: { undo: '撤销', diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 4424db15c9..e2fb9d8535 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -1684,6 +1684,19 @@ const OBJECT_CHART_DATA_SOURCE: ElementDataSourceMapping = { * no new type is minted. Pinned by * `__tests__/ObjectChartBlock.props-8885.test.tsx`. */ +/** + * Whether a chart node has no record source but its object (objectui#11605): + * no inline `data`, no `dataset`, no `bind` path. `ObjectChart` fetches only by + * `objectName` or `dataset`, and draws inline or bound rows without either. + * + * Read through a weak type rather than off `BaseSchema` by name: `dataset` is + * this block's key, not a `BaseSchema` member. + */ +const chartNeedsObject = (node: BaseSchema): boolean => { + const sources = node as { data?: unknown; dataset?: unknown; bind?: unknown }; + return sources.data == null && sources.dataset == null && sources.bind == null; +}; + export const ObjectChartBlock = elementDataSourceBlock( (props: Omit & { schema: BaseSchema }) => ( {(bound) => ( // The ONE loose member of this signature, kept on purpose. This is @@ -1712,7 +1730,18 @@ ComponentRegistry.register('object-chart', ObjectChartBlock, { label: 'Object Chart', category: 'view', inputs: [ - { name: 'objectName', type: 'string', required: true }, + // NOT required (objectui#11605). This block has no `ComponentPropsMap` + // row; the contract is the binding doc (`content/docs/guide/data-source.md`, + // "a node bound this way needs no `objectName` of its own"), and + // `ObjectChartBlock` lands `dataSource.object` here. The page compile + // reads this list, so `required: true` refused a bound node the + // renderer accepts. A node with neither is answered by the gate's hint. + { + name: 'objectName', + type: 'string', + description: + 'Object this chart aggregates. 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, and no inline `data`, the chart shows a hint naming this key instead of an empty frame.', + }, { name: 'data', type: 'array', description: 'Optional static data' }, { name: 'filter', type: 'array' }, { name: 'aggregate', type: 'object', description: 'Aggregation config: { field, function, groupBy }' }, diff --git a/packages/plugin-charts/src/index.tsx b/packages/plugin-charts/src/index.tsx index ee578d8101..9ffa388ba0 100644 --- a/packages/plugin-charts/src/index.tsx +++ b/packages/plugin-charts/src/index.tsx @@ -82,7 +82,14 @@ ComponentRegistry.register('chart', ObjectChartBlock, { label: 'Chart', skipFallback: true, inputs: [ - { name: 'objectName', type: 'string', required: true }, + // NOT required, for `object-chart`'s reason (objectui#11605): the same + // `ObjectChartBlock` lands `dataSource.object` here. + { + name: 'objectName', + type: 'string', + description: + 'Object this chart aggregates. 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, and no inline `data`, the chart shows a hint naming this key instead of an empty frame.', + }, { name: 'type', type: 'string' }, { name: 'categoryField', type: 'string' }, { name: 'valueField', type: 'string' }, diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 1456c8f220..815076e477 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -240,6 +240,12 @@ const ObjectMetricBlock: React.FC<{ schema?: any; [key: string]: any }> = elemen dataSource={props.dataSource} testId="object-metric" errorTitle="This metric’s data source could not be resolved" + // A metric that names its object in neither place has nothing to + // aggregate, and drew a bare dash, which reads as a value (objectui#11605). + // A host rendering this widget with explicit props and no schema is not a + // node and is left alone, and an authored `fallbackValue` is a static tile + // the author chose. + requiresObject={schema != null && schema.fallbackValue === undefined} > {(bound) => ( = element dataSource={props.dataSource} testId="object-pivot" errorTitle="This pivot table’s data source could not be resolved" + // A pivot that names its object in neither place has nothing to fetch, and + // drew the empty state that says its query "returned no records yet" — a + // query it never ran (objectui#11605). Inline `data` rows and a `bind` + // path are this table's other record sources, and a host rendering it with + // no schema is not a node. + requiresObject={schema != null && schema.data == null && schema.bind == null} > {(bound) => } @@ -395,7 +419,18 @@ ComponentRegistry.register( category: 'Dashboard', icon: 'table-2', inputs: [ - { name: 'objectName', type: 'string', required: true }, + // NOT required (objectui#11605). This block has no `ComponentPropsMap` + // row; the contract is the binding doc (`content/docs/guide/data-source.md`, + // "a node bound this way needs no `objectName` of its own"), and + // `ObjectPivotBlock` lands `dataSource.object` here. The page compile reads + // this list, so `required: true` refused a bound node the renderer + // accepts. A node with neither is answered by the gate's hint. + { + name: 'objectName', + type: 'string', + description: + 'Object this pivot table cross-tabulates. 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 table shows a hint naming this key and fetches nothing.', + }, { name: 'title', type: 'string' }, { name: 'rowField', type: 'string', required: true }, { name: 'columnField', type: 'string', required: true }, diff --git a/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts b/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts index 5cbfa2e17d..d3cd8e6ffd 100644 --- a/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts +++ b/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts @@ -12,9 +12,11 @@ * * `@object-ui/types/zod`'s `EmbeddableFormBlockSchema` builds its `properties` * bag from exactly this registration's `inputs` (the spec has no - * `ComponentPropsMap` row for the block), and requires what the registration - * requires: `formId` always, and `objectName` unless the node's - * `dataSource.object` names the object (the registration is gate-wrapped). + * `ComponentPropsMap` row for the block). The arm requires `formId` always, + * and `objectName` unless the node's `dataSource.object` names the object + * (the registration is gate-wrapped). The registration requires `formId` only + * (objectui#11605): its `required` is what the page compile reads, and the + * compile has no "this key or that binding" form. * This file holds the two lists equal in both directions. The one input that * is not a bag member is `dataSource`, the binding the gate-wrapped * registration publishes (objectui#6678), which the arm declares on the NODE. @@ -47,9 +49,12 @@ describe('embeddable-form — registration inputs and the arm\'s bag agree (obje expect(Object.keys(EmbeddableFormBlockSchema.shape)).toContain('dataSource'); }); - it('`formId` is required on both; `objectName` is required by the registration and waived by the binding in the arm', () => { + it('`formId` is required on both; `objectName` is required on neither, because the binding can supply it', () => { const required = inputs().filter((input) => input.required).map((input) => input.name).sort(); - expect(required).toEqual(['formId', 'objectName']); + // `objectName` left this list with objectui#11605: the page compile reads + // it, and `required: true` refused a node whose `dataSource.object` names + // the object. + expect(required).toEqual(['formId']); expect(bag().formId.isOptional()).toBe(false); // The arm keeps `objectName` omissible as a member and enforces it with // `requireRecordSource` unless `dataSource.object` is present. diff --git a/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx b/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx index 5878418c00..a4dd15d437 100644 --- a/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx +++ b/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx @@ -12,7 +12,7 @@ * it as a React prop and never as context: measured `getObjectSchema` **0**, * `findOne` **0**, a card with no fields and no error. The key axis compounded * it — the page named the object with `object`, and this block declares - * `objectName` as required. + * `objectName` (as required then; not since objectui#11605). * * ## The correction this file exists to keep * diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index e9b208727d..69df173d5b 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -7,7 +7,7 @@ */ import React, { useContext } from 'react'; -import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, SchemaRendererContext, @@ -210,25 +210,45 @@ const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = elem // (objectui#5378 item 2). The one escape hatch is inline `customFields`, // which is exactly what `hasInlineFields` gates on inside the component, // so the two stay in step. A form with no `objectName` is a different - // defect and is left to report itself. + // defect, answered by `requiresObject` below. requiresDataSource={ !(schema?.customFields?.length > 0) && typeof schema?.objectName === 'string' && schema.objectName.length > 0 } noDataSourceMessage={noDataSourceMessage('object-form', schema?.objectName)} + // A form that names its object in neither place (objectui#11605) drew a + // field-less card with Cancel and Update buttons. Inline `customFields` + // is again the one escape hatch. + requiresObject={!(schema?.customFields?.length > 0)} > {(bound) => } ); }); +/** + * The `objectName` input `object-form` and `view:form` publish (one renderer, + * one declaration). NOT required, as on the spec row (objectui#11605): + * `ComponentPropsMap['object-form']` leaves `objectName` optional "because the + * component-level `dataSource` binding can supply the object instead", and + * `ObjectFormRenderer` lands `dataSource.object` here. The page compile reads + * this list, so `required: true` refused a bound node the row and the renderer + * accept. A node with neither is answered by the gate's "no object named" hint. + */ +const OBJECT_FORM_OBJECT_NAME_INPUT: ComponentInput = { + name: 'objectName', + type: 'string', + description: + 'Object this form creates or edits. 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, and no inline `customFields`, the form shows a hint naming this key instead of a form with no fields.', +}; + ComponentRegistry.register('object-form', ObjectFormRenderer, { namespace: 'plugin-form', label: 'Object Form', category: 'plugin', inputs: [ - { name: 'objectName', type: 'string', required: true }, + { ...OBJECT_FORM_OBJECT_NAME_INPUT }, { name: 'fields', type: 'array', description: 'Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (SimpleObjectForm in ObjectForm.tsx; buildFlatFields in flatFields.ts for the drawer/modal presentations).' }, { name: 'mode', type: 'enum', enum: ['create', 'edit', 'view'] }, { name: 'formType', type: 'enum', enum: ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'] }, @@ -337,7 +357,7 @@ ComponentRegistry.register('form', ObjectFormRenderer, { label: 'Data Form View', category: 'view', inputs: [ - { name: 'objectName', type: 'string', required: true }, + { ...OBJECT_FORM_OBJECT_NAME_INPUT }, { name: 'fields', type: 'array', description: 'Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (this renders through the same `ObjectFormRenderer` / `SimpleObjectForm` as `object-form` above — see its `fields` description).' }, { name: 'mode', type: 'enum', enum: ['create', 'edit', 'view'] }, ] @@ -385,6 +405,10 @@ const EmbeddableFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlock dataSource={dataSource} testId="embeddable-form" errorTitle="This form’s data source could not be resolved" + // A public form that names its object in neither place has no fields to + // fetch and no object to `create()` the submission in, and drew an empty + // form with a Submit button (objectui#11605). + requiresObject > {(bound) => } @@ -397,7 +421,18 @@ ComponentRegistry.register('embeddable-form', EmbeddableFormRenderer, { category: 'plugin', inputs: [ { name: 'formId', type: 'string', required: true }, - { name: 'objectName', type: 'string', required: true }, + // NOT required (objectui#11605). This block has no `ComponentPropsMap` row; + // the contract is the binding doc (`content/docs/guide/data-source.md`, "a + // node bound this way needs no `objectName` of its own"), and + // `EmbeddableFormRenderer` lands `dataSource.object` here. The page compile + // reads this list, so `required: true` refused a bound node the renderer + // accepts. A node with neither is answered by the gate's hint. + { + name: 'objectName', + type: 'string', + description: + 'Object the form creates a record in. 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 form shows a hint naming this key instead of a form that cannot submit.', + }, { name: 'title', type: 'string' }, { name: 'description', type: 'string' }, { name: 'fields', type: 'array', description: 'Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (`EmbeddableForm` passes this array straight through to `` with no `sections`, so it renders through the same `SimpleObjectForm` as `object-form` above — see its `fields` description).' }, @@ -457,6 +492,10 @@ const MasterDetailFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlo dataSource={dataSource} testId="object-master-detail-form" errorTitle="This form’s data source could not be resolved" + // With no parent object in either place there is no parent form to draw + // and no relationship to derive: it drew an empty parent and a detail + // hint about a missing link to a blank parent (objectui#11605). + requiresObject > {(bound) => } @@ -468,7 +507,19 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer label: 'Master-Detail Form', category: 'plugin', inputs: [ - { name: 'objectName', type: 'string', required: true }, + // NOT required, as on the spec row (objectui#11605): + // `ComponentPropsMap['object-master-detail-form']` leaves the PARENT + // `objectName` optional "because the component-level `dataSource` binding + // can supply the object instead", and `MasterDetailFormRenderer` lands + // `dataSource.object` here. The page compile reads this list, so + // `required: true` refused a bound node the row and the renderer accept. A + // node with neither is answered by the gate's hint. + { + name: 'objectName', + type: 'string', + description: + 'The PARENT object this form creates or edits. 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 form shows a hint naming this key instead of an empty parent form.', + }, { name: 'mode', type: 'enum', enum: ['create', 'edit'] }, { name: 'sections', type: 'array' }, { name: 'details', type: 'array', required: true }, diff --git a/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx b/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx index 176e64e98e..1db2401d33 100644 --- a/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx +++ b/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx @@ -19,8 +19,8 @@ * which never becomes context; this block reads the adapter from * `SchemaRendererProvider`. Measured `find` **0**. * 3. **keys** (#5377) — it named the object with `object`, and this block - * declares `objectName` (`GRID_QUERY_INPUTS`, `required: true`). Measured - * `find` **0** even under the right wiring. + * declares `objectName` (`GRID_QUERY_INPUTS`; `required: true` then, not + * since objectui#11605). Measured `find` **0** even under the right wiring. * 4. **capability** (#5446) — Step 7 named a top-level `view` and a * `data.queryParams.$search`, neither of which `ObjectGrid` reads at all * (`schema.view` — zero hits; `data` is the `ViewData` union, `queryParams` diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 483f3d0812..103b678daf 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -247,7 +247,19 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * read as back-compat, not advertised as authoring surface. */ const GRID_QUERY_INPUTS: ComponentInput[] = [ - { name: 'objectName', type: 'string', required: true }, + // NOT required, as on the spec row (objectui#11605): `ComponentPropsMap + // ['object-grid']` leaves `objectName` optional "because the component-level + // `dataSource` binding can supply the object instead", and the gate above + // lands `dataSource.object` here. The page compile reads this list, so + // `required: true` refused a bound node the row and the renderer accept. A + // node with neither is answered by `ObjectGrid`'s own "object name required" + // error, unchanged. + { + name: 'objectName', + type: 'string', + description: + 'Object this grid lists. 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, and no inline `data` rows, the grid shows an error saying an object name is required and fetches nothing.', + }, { name: 'columns', type: 'array', description: 'Columns to show, either field names (`["name", "email"]`) or column objects (`[{ field: "name", label: "Full Name", width: 200 }]`). The canonical spelling — the deprecated `fields` is only read when this is absent.' }, { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form. The canonical spelling — the deprecated `defaultFilters` is only read when this is absent.' }, // ── identity ────────────────────────────────────────────────────────────── diff --git a/packages/plugin-kanban/src/index.test.ts b/packages/plugin-kanban/src/index.test.ts index 55b0124f9c..8ec4a4c2e2 100644 --- a/packages/plugin-kanban/src/index.test.ts +++ b/packages/plugin-kanban/src/index.test.ts @@ -55,14 +55,15 @@ describe('Plugin Kanban', () => { ); }); - it('should have objectName as required input', () => { + it('declares objectName as not required, naming the binding that can supply it (objectui#11605)', () => { + // The spec row leaves `objectName` optional because the node's + // `dataSource` binding can supply the object, and the page compile reads + // this list: `required: true` here refused a bound board. const config = ComponentRegistry.getConfig('object-kanban'); const objectName = (config?.inputs ?? []).find((i) => i.name === 'objectName'); expect(objectName).toBeDefined(); - expect(objectName?.required).toBe(true); - // Control: not every declared input is required, so `true` above is a - // reading of this entry and not of a shape that marks everything. - expect((config?.inputs ?? []).find((i) => i.name === 'columns')?.required).toBeFalsy(); + expect(objectName?.required).not.toBe(true); + expect(objectName?.description ?? '').toContain('`dataSource.object`'); }); it('every declared input carries a type', () => { diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index a2fee7368f..a452c3e682 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -510,6 +510,17 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> dataSource={dataSource} testId="object-kanban" errorTitle="This board’s data source could not be resolved" + // A board that names its object in neither place has nothing to fetch, + // and drew an empty board with "No cards" — the answer an empty query + // gives (objectui#11605). Every other rung of the board's record-source + // ladder supplies rows without an object: records a parent view hands + // down (`data` prop), a `bind` path, and inline `data` (an empty array is + // a static board, so presence is the test, not length). + requiresObject={ + schema?.data == null + && schema?.bind == null + && !Array.isArray((props as { data?: unknown }).data) + } > {(bound) => } @@ -661,7 +672,19 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * `__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx`. */ const OBJECT_KANBAN_INPUTS: ComponentInput[] = [ - { name: 'objectName', type: 'string', required: true }, + // NOT required, as on the spec row (objectui#11605): `ComponentPropsMap + // ['object-kanban']` leaves `objectName` optional "because the + // component-level `dataSource` binding can supply the object instead", and + // the gate in `ObjectKanbanRenderer` lands `dataSource.object` here. The page + // compile reads this list, so `required: true` refused a bound node the row + // and the renderer accept. A node with neither is answered by the gate's + // "no object named" hint (`requiresObject` above). + { + name: 'objectName', + type: 'string', + description: + 'Object this board lists. 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, and no rows from `data`, `bind` or a parent view, the board shows a hint naming this key instead of an empty board.', + }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query.' }, { name: 'limit', type: 'number', description: 'Row cap — the most records the board fetches, lowered to the query’s top-level `$top` (renderer default 100). The board renders every fetched record into a lane and offers no pagination, so this is the author’s window on the object rather than a page size. PRECEDENCE: a node-level `dataSource` binding’s own `limit` wins outright; the `pagination.pageSize` of a view that binding names fills this key only when the node leaves it unset.' }, diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 5d3a15525e..7e7f906dda 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -120,6 +120,11 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef {(schema) => } diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 32d1d73ced..480b18ff0f 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -7,7 +7,7 @@ */ import { ComponentRegistry } from '@object-ui/core'; -import type { ListViewVisualization } from '@object-ui/core'; +import type { ComponentInput, ListViewVisualization } from '@object-ui/core'; import { ListView } from './ListView'; import { ListViewBlock } from './ListViewBlock'; import { ViewSwitcher } from './ViewSwitcher'; @@ -66,6 +66,18 @@ const VIEW_TYPE_OPTIONS = Object.entries(VIEW_TYPE_LABELS).map(([value, label]) const ListViewRenderer = ListViewBlock; +/** + * The `objectName` input both registrations below publish, spelled once so the + * two cannot drift. Not required (objectui#11605): see the `list-view` + * registration. + */ +const LIST_VIEW_OBJECT_NAME_INPUT: ComponentInput = { + name: 'objectName', + type: 'string', + description: + 'Object this list view lists. 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, and no inline `data`, the list shows a hint naming this key instead of an empty list.', +} as const; + // Register ListView component ComponentRegistry.register('list-view', ListViewRenderer, { namespace: 'plugin-list', @@ -73,7 +85,15 @@ ComponentRegistry.register('list-view', ListViewRenderer, { category: 'Views', icon: 'LayoutList', inputs: [ - { name: 'objectName', type: 'string', required: true }, + // NOT required (objectui#11605). This block has no `ComponentPropsMap` + // row; the contract is the binding doc (`content/docs/guide/data-source.md`, + // "a node bound this way needs no `objectName` of its own") and the + // `list-view` record-source rule `objectui validate` runs, which counts the + // binding as a record source. `ListViewBlock` lands `dataSource.object` + // here. The page compile reads this list, so `required: true` refused a + // bound node the validator and the renderer accept. A node with neither is + // answered by the gate's "no object named" hint. + { ...LIST_VIEW_OBJECT_NAME_INPUT }, { name: 'viewType', type: 'enum', enum: VIEW_TYPE_OPTIONS }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array' }, @@ -106,7 +126,8 @@ ComponentRegistry.register('list', ListViewRenderer, { label: 'List', icon: 'LayoutList', inputs: [ - { name: 'objectName', type: 'string', required: true }, + // Same renderer, same reason as `list-view` above. + { ...LIST_VIEW_OBJECT_NAME_INPUT }, { name: 'viewType', type: 'enum', enum: VIEW_TYPE_OPTIONS }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array' }, diff --git a/packages/react/README.md b/packages/react/README.md index bf60f2e8ba..70c7d9f6c4 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -216,6 +216,15 @@ const ObjectGridRenderer = elementDataSourceBlock>(({ )) ``` +**Say when the placement cannot draw anything.** Two boolean props let the call +site, which knows its block's other record sources, ask the gate to answer instead +of mounting an empty block. `requiresDataSource` draws a "no data source" panel +when no adapter resolves. `requiresObject` draws a short "No object named: set +objectName or dataSource.object." hint when the node names its object in neither +place, read after the binding lands on the mapping's object key, so a node bound +by `dataSource.object` never sees it (objectui#11605). Leave either one false for +a placement that draws from inline rows or another source the block reads. + **Wrap the registered renderer in `elementDataSourceBlock`.** It is what makes `ComponentRegistry.register` emit the `dataSource` input on that block's authoring surface, so the key the gate READS is also the key the manifest, the diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index e027f66ace..99671a24e1 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -598,6 +598,55 @@ function ElementDataSourceMalformedFilterPanel({ ); } +/** + * The "no object named" hint, drawn IN PLACE OF the block when a placement + * that opted in with `requiresObject` names its object in neither place: not + * on the block's own object key, and not through `dataSource.object` + * (objectui#11605). + * + * Why it exists: the object-bound registrations no longer declare their object + * key `required`, because the binding can supply it and the manifest has no + * "this key or that binding" form. So the page compile accepts a node that + * names no object at all, and this hint is the one signal left for it. Before + * it, those blocks drew an empty board, a field-less form, a dash or a "no + * data yet" state, which reads as an empty query rather than a missing object. + * + * The wording is `element:number`'s own no-object notice (objectui#10951) with + * the property as a hole, so the two blocks that answer this question say it + * the same way. The property name is interpolated, never translated. + * + * A separate component for the reason {@link ElementDataSourceMalformedFilterPanel} + * gives: `useObjectTranslation` runs only on this path, and the provider-less + * default is a literal copy of the `en` pack's value, held byte-identical to it + * by `pnpm check:i18n-keys`. + */ +function ElementDataSourceNoObjectPanel({ + testId, + property, +}: { + testId: string; + property: string; +}): React.ReactElement { + const { t } = useObjectTranslation(); + return ( +
+ {t('view.noObject', { + property, + defaultValue: 'No object named: set {{property}} or dataSource.object.', + })} +
+ ); +} + +/** + * Does the bound schema name an object on `key`? A blank string names nothing, + * the same reading `noDataSourceMessage` gives an object name. + */ +const namesObject = (schema: unknown, key: string): boolean => { + const value = (schema as Record | null | undefined)?.[key]; + return typeof value === 'string' && value.trim().length > 0; +}; + /** * The "saved views are still being fetched" placeholder. Distinct from the error * panel because a component that treated "not resolved yet" as "does not exist" @@ -784,6 +833,24 @@ export interface ElementDataSourceGateProps { requiresDataSource?: boolean; /** Explanation for the no-adapter panel; see {@link noDataSourceMessage}. */ noDataSourceMessage?: string; + /** + * Whether THIS placement has nothing to show unless it names an object — when + * true and the bound schema names none on the mapping's object key (neither + * the node's own key nor `dataSource.object` landing on it), the gate renders + * the "no object named" hint instead of the block (objectui#11605). + * + * Read AFTER the binding is applied, so a node bound by `dataSource.object` + * is never told it names no object; and AFTER the view states, so a binding + * that is still resolving or failed to resolve keeps its own panel. + * + * The call site states it, for the reason {@link requiresDataSource} gives: + * the other record sources a block can draw from (inline `data`, inline + * `customFields`, a `dataset`, a `bind` path) are each block's own, and a + * predicate guessed here would paint a configuration hint over a block that + * is working. A mapping whose `object` is `false` lands no object key, so + * this prop does nothing there. + */ + requiresObject?: boolean; /** * Renders the block with the bound schema. Called during the gate's own * render, so it must RETURN AN ELEMENT and never call hooks itself — the @@ -809,6 +876,7 @@ export function ElementDataSourceGate({ errorTitle, requiresDataSource, noDataSourceMessage: noDataSourceText, + requiresObject, children, }: ElementDataSourceGateProps): React.ReactElement | null { const bound = useElementDataSourceSchema(schema, mapping, dataSource); @@ -835,5 +903,12 @@ export function ElementDataSourceGate({ if (bound.status === 'loading') { return ; } + // Named in neither place: not on the block's own key, and not by a binding + // (whose `object` the hook above has already landed on that key). See + // `requiresObject` for why the call site, not this gate, decides it applies. + const objectKey = mapping?.object ?? 'objectName'; + if (requiresObject && objectKey !== false && !namesObject(bound.schema, objectKey)) { + return ; + } return children(bound.schema); } diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx new file mode 100644 index 0000000000..43e5a84209 --- /dev/null +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx @@ -0,0 +1,102 @@ +/** + * 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. + * + * `requiresObject`: the gate draws the "no object named" hint in place of the + * block when the placement opted in and names its object in neither place — + * objectui#11605. + * + * The object-bound registrations stopped declaring their object key + * `required`, because the binding can supply it, so the page compile accepts a + * node that names no object at all. This hint is the answer for that node. + * The rows below hold the three things the gate decides: it reads the key AFTER + * the binding lands (a bound node is never told it names no object), it reads + * the MAPPING's key (not a hard-wired `objectName`), and it does nothing unless + * the call site opted in. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import * as React from 'react'; +import { ElementDataSourceGate, type ElementDataSourceMapping } from '../ElementDataSourceGate'; + +const makeAdapter = () => ({ + find: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', listViews: {} }), +}); + +function Block({ schema }: { schema: any }) { + return
{JSON.stringify(schema)}
; +} + +const renderGate = ( + schema: Record, + { requiresObject, mapping }: { requiresObject?: boolean; mapping?: ElementDataSourceMapping } = {}, +) => + render( + + {(bound) => } + , + ); + +describe('ElementDataSourceGate — requiresObject (objectui#11605)', () => { + it('a placement that opted in and names no object draws the hint, naming the key, instead of the block', () => { + const { queryByTestId, getByTestId } = renderGate({ type: 'probe' }, { requiresObject: true }); + expect(queryByTestId('block')).toBeNull(); + expect(getByTestId('probe-no-object').textContent).toBe( + 'No object named: set objectName or dataSource.object.', + ); + }); + + it('a blank object name names nothing', () => { + const { queryByTestId } = renderGate({ type: 'probe', objectName: ' ' }, { requiresObject: true }); + expect(queryByTestId('probe-no-object')).not.toBeNull(); + }); + + it('control: the node names its object on its own key, so the block renders', () => { + const { queryByTestId } = renderGate({ type: 'probe', objectName: 'account' }, { requiresObject: true }); + expect(queryByTestId('probe-no-object')).toBeNull(); + expect(queryByTestId('block')).not.toBeNull(); + }); + + it('control: the binding names the object and the gate lands it before the check, so the block renders', async () => { + const { queryByTestId, findByTestId } = renderGate( + { type: 'probe', dataSource: { object: 'account' } }, + { requiresObject: true }, + ); + const block = await findByTestId('block'); + expect(queryByTestId('probe-no-object')).toBeNull(); + expect(JSON.parse(block.textContent ?? '{}').objectName).toBe('account'); + }); + + it('control: without the opt-in the block renders, as before', () => { + const { queryByTestId } = renderGate({ type: 'probe' }); + expect(queryByTestId('probe-no-object')).toBeNull(); + expect(queryByTestId('block')).not.toBeNull(); + }); + + it('the hint names the MAPPING\'s object key', () => { + const { getByTestId } = renderGate( + { type: 'probe' }, + { requiresObject: true, mapping: { object: 'childObject' } }, + ); + expect(getByTestId('probe-no-object').textContent).toBe( + 'No object named: set childObject or dataSource.object.', + ); + }); + + it('a mapping that lands no object key never draws the hint', () => { + const { queryByTestId } = renderGate({ type: 'probe' }, { requiresObject: true, mapping: { object: false } }); + expect(queryByTestId('probe-no-object')).toBeNull(); + expect(queryByTestId('block')).not.toBeNull(); + }); +}); From cd82e54f5a5637657df93fbba2dcb65876922f5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:12:14 +0000 Subject: [PATCH 2/8] fix(plugin-kanban): lanes that carry their own cards are a record source, so a static board shows no no-object hint (objectui#11605) The board keeps a lane's own `cards` when it merges rows into its lanes, so a board authored with card-carrying `columns` and no object draws cards. The kanban call site now opts out of `requiresObject` for it, and the neither-hint pin gains that control. Two fixtures follow the hint: the kanban registration probe names its object, and the html-tier `object-name` spelling (6598 form 8a) now fails loudly through the block's hint rather than the compile. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- .../objectname-neither-hint-11605.test.tsx | 15 ++++++++++- packages/plugin-kanban/src/index.tsx | 9 ++++++- .../plugin-kanban/src/registration.test.tsx | 5 +++- .../htmlTierColumnSpellings-6598.test.tsx | 26 ++++++++++++++----- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx index a7ec81b0e1..24b41bf6a8 100644 --- a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx +++ b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx @@ -30,7 +30,8 @@ * 3. Control: the same node bound by `dataSource.object` draws no hint. * 4. Control, where the block has another record source: the neither node with * that source draws no hint, so the hint is not painted over a block that - * is working. + * is working. `object-kanban` has two such rows: inline `data`, and lanes + * that carry their own `cards`. */ import { describe, it, expect } from 'vitest'; @@ -180,4 +181,16 @@ describe('objectui#11605 — a node naming its object in neither place shows the expect(r.hasHint(testId)).toBe(false); }, ); + + it('object-kanban — control: lanes that carry their own cards are a record source, no hint', async () => { + // A static board: the board keeps a lane's own `cards` when it merges rows + // into its lanes, so this node draws cards with no object and no `data`. + const r = await mount({ + type: 'object-kanban', + columns: [{ id: 'todo', title: 'To Do', cards: [{ id: '1', title: 'Static card' }] }], + }); + expect(r.html).not.toContain('failed to render'); + expect(r.hasHint('object-kanban')).toBe(false); + expect(r.text).toContain('Static card'); + }); }); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index a452c3e682..54919dea47 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -490,6 +490,10 @@ const OBJECT_KANBAN_DATA_SOURCE: ElementDataSourceMapping = { limit: 'limit', }; +/** Does an authored `columns` list carry lanes with their own `cards`? */ +const lanesCarryCards = (columns: unknown): boolean => + Array.isArray(columns) && columns.some((column) => Array.isArray((column as { cards?: unknown } | null)?.cards)); + // Register object-kanban for ListView integration export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => { // `useSchemaContext()` may hand back a NULL adapter: a host with nothing @@ -515,11 +519,14 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> // gives (objectui#11605). Every other rung of the board's record-source // ladder supplies rows without an object: records a parent view hands // down (`data` prop), a `bind` path, and inline `data` (an empty array is - // a static board, so presence is the test, not length). + // a static board, so presence is the test, not length). So do lanes that + // carry their own `cards`, which the board keeps ("Preserve static cards" + // in the column merge above). requiresObject={ schema?.data == null && schema?.bind == null && !Array.isArray((props as { data?: unknown }).data) + && !lanesCarryCards(schema?.columns) } > {(bound) => } diff --git a/packages/plugin-kanban/src/registration.test.tsx b/packages/plugin-kanban/src/registration.test.tsx index cac58ee2b8..df1e031078 100644 --- a/packages/plugin-kanban/src/registration.test.tsx +++ b/packages/plugin-kanban/src/registration.test.tsx @@ -34,7 +34,10 @@ vi.mock('./ObjectKanban', () => ({ describe('Plugin Kanban Registration', () => { it('renderer passes dataSource from context', () => { - render(); + // The board names its object: a node with neither `objectName` nor a + // binding is answered by the gate's no-object hint (objectui#11605), and + // never reaches the board this probe stubs. + render(); expect(screen.getByTestId('kanban-mock')).toHaveTextContent('DataSource: mock-datasource'); }); }); diff --git a/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx b/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx index 3aa9b1b124..f70a9b3e53 100644 --- a/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx +++ b/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx @@ -27,7 +27,9 @@ * projection at zero and neither default-columns derivation could run * (fixed by objectui#6598's first half, PR #6679); * - two spellings that were never the contract at all (`viewName` / `view`), - * plus two that are page-fatal compile errors. + * plus two that fail loudly: one a page-fatal compile error, and one the + * block answers with its "no object named" hint (a compile error too until + * objectui#11605 stopped `list-view` requiring `objectName`). * * Sibling files pin the mechanisms one at a time: * `literal-subset-6614.test.ts` and `inert-expression-6598.test.ts` in @@ -51,7 +53,8 @@ * disjunction — each form either * * (a) renders a table with at least one DATA column, or - * (b) fails LOUDLY, with the compile-error block on screen. + * (b) fails LOUDLY, with the compile-error block on screen, or with the + * block's own "no object named" hint naming the key it is missing. * * What no form may do is what all eight did when the card was filed: render a * populated table whose only header is the index column. A spelling moving @@ -103,6 +106,8 @@ interface Rendered { headers: string[]; dataHeaders: string[]; compileFailed: boolean; + /** The block's "no object named" hint (objectui#11605). */ + noObjectHint: boolean; text: string; } @@ -122,6 +127,7 @@ async function renderSpelling(source: string): Promise { await waitFor(() => { const settled = container.querySelector('table') !== null || + container.querySelector('[data-testid="list-view-no-object"]') !== null || /failed to compile/i.test(container.textContent || ''); expect(settled).toBe(true); }); @@ -131,6 +137,7 @@ async function renderSpelling(source: string): Promise { headers, dataHeaders: headers.filter((h) => h !== INDEX_COLUMN), compileFailed: /failed to compile/i.test(text), + noObjectHint: container.querySelector('[data-testid="list-view-no-object"]') !== null, text, }; } @@ -138,7 +145,7 @@ async function renderSpelling(source: string): Promise { /** The reported state, named once so every case can assert against it. */ function expectNotTheReportedSymptom(r: Rendered): void { const renderedDataColumns = !r.compileFailed && r.dataHeaders.length > 0; - const failedLoudly = r.compileFailed; + const failedLoudly = r.compileFailed || r.noObjectHint; // Before the fixes: `headers` was exactly ['#'] with `compileFailed` false — // rows on screen, no data columns, and nothing said so. expect(renderedDataColumns || failedLoudly).toBe(true); @@ -222,7 +229,7 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page }); }); - describe('the spellings that are page-fatal — they fail LOUDLY, which is the opposite of the report', () => { + describe('the spellings that fail LOUDLY, which is the opposite of the report', () => { it('form 6 — child elements', async () => { const r = await renderSpelling( ``, @@ -236,13 +243,18 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page }); it('form 8a — the kebab-case `object-name` variant', async () => { - // `object-name` is not `objectName`, so the required prop is simply - // absent and the page says so by name. + // `object-name` is not `objectName`, so the list names no object, and + // the page says so by name. Since objectui#11605 the compile no longer + // refuses it (`objectName` is not required: a `dataSource` binding can + // supply it), so the saying moved from the compile-error block to the + // block's own hint, which names `objectName` and fetches nothing. const r = await renderSpelling(``); expectNotTheReportedSymptom(r); - expect(r.compileFailed).toBe(true); + expect(r.compileFailed).toBe(false); + expect(r.noObjectHint).toBe(true); expect(r.text).toContain('objectName'); + expect(r.headers).toEqual([]); }); }); }); From a96a84467ce71258e2bc9c31949a4db6512723f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:44:29 +0000 Subject: [PATCH 3/8] chore(scripts): adjudicate the objectui#11605 enumeration pin's read of the binding doc The pin parses `content/docs/guide/data-source.md`'s "which keys each one honours" table, so the markdown-test-inputs ledger records that read and a markdown-only change to that page runs the pin. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- scripts/markdown-test-inputs.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 3c5dedfc41..20df9c8d7d 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -205,6 +205,13 @@ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage', * something else, and says what. */ export const ADJUDICATED = new Map([ + // objectui#11605. `bindingDocRows()` parses the binding doc's "which keys each + // one honours" table to learn what the `dataSource` binding supplies on each + // bound block -- so an edit to that table is an edit to this pin's input. + [ + 'apps/console/src/__tests__/objectname-binding-required-11605.test.ts', + { reads: ['content/docs/guide/data-source.md'] }, + ], // objectui#11403. The console's SDUI manifest pin lists the files the // generator writes into a temp directory; no markdown in this tree is read. [ From 6bc4faeb517c4d7fc48dc43b5826adc3e8cd3632 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:46:39 +0000 Subject: [PATCH 4/8] test(console): the enumeration pin's requiredness control names objectName, so it reads the same before and after (objectui#11605) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- .../src/__tests__/objectname-binding-required-11605.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts index 192c63a585..2de0987b3a 100644 --- a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts +++ b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts @@ -297,8 +297,10 @@ describe('objectui#11605 — each member compiles a bound node, and leaves the n it('control: the gate still judges requiredness on a moved tag', () => { // The bound node above names `formId`; without it the same tag is refused, - // so the empty list there is a reading of this entry. - const source = nodeOf('embeddable-form', 'dataSource={{ object: "account" }}'); + // so the empty list there is a reading of this entry. The node names + // `objectName`, so this row reads the same before and after the fix: it is + // a control on the gate, not a second pin. + const source = nodeOf('embeddable-form', 'objectName="lead"'); expect(diagnosticsOf(source)).toEqual([ ['error', 'missing-required-prop', ' is missing required prop "formId"'], ]); From a037cbb4750be5de27b49ab59c265b9cf95fe55c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:01:00 +0000 Subject: [PATCH 5/8] test(console): the 8510 stripped-binding control waits on the list's no-object hint (objectui#11605) With the `data` binding stripped, the workbench's list names no object, so the block now draws the "no object named" hint instead of mounting `ListView`. The control's data-call assertions are unchanged; only its "the block has answered" signal moves to the hint. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- ...workbench-preview-listview-binding-8510.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx b/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx index a014cc9c11..38064ef8f5 100644 --- a/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx +++ b/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx @@ -31,9 +31,9 @@ * (`getObjectSchema`, which nothing else on the page calls while the form is * closed) and its own query, which selects the preview's columns — and a * control renders the SAME extracted source with the `data` binding stripped: - * once ListView reports its fetch effect settled (`data-state="idle"` on its - * root), neither of its data calls may have happened and the stats query must - * be the only one. The contract case carries a control too: the same source + * once the list block has answered (since objectui#11605 a list that names no + * object draws the "no object named" hint in place of `ListView`), neither of + * its data calls may have happened and the stats query must be the only one. The contract case carries a control too: the same source * with the retired spelling put back must go red. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -167,10 +167,11 @@ describe('sdui-workbench-preview — the react-tier ListView binding (objectui#8 const { dataSource, container } = renderWorkbench(stripped); - // ListView's root reports `idle` only after its fetch effect has run — - // the branch a query would have started from. + // With no object named, the list block draws the "no object named" hint + // instead of mounting `ListView` (objectui#11605), so the hint is what + // says the block has answered. await waitFor(() => - expect(container.querySelector('[role="region"][data-state="idle"]')).not.toBeNull(), + expect(container.querySelector('[data-testid="list-view-no-object"]')).not.toBeNull(), ); expect(dataSource.getObjectSchema).not.toHaveBeenCalled(); expect(dataSource.find.mock.calls.filter(([, params]) => !isStatsQuery(params))).toEqual([]); From 582982ed7cc810cd3085ead536c1d6dfc7304ca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:14:10 +0000 Subject: [PATCH 6/8] test(react): the requiresObject pin's stub block takes `unknown`, not `any` (objectui#11605) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- .../ElementDataSourceGate.requiresObject-11605.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx index 43e5a84209..00e70b5ee3 100644 --- a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.requiresObject-11605.test.tsx @@ -28,7 +28,7 @@ const makeAdapter = () => ({ getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', listViews: {} }), }); -function Block({ schema }: { schema: any }) { +function Block({ schema }: { schema: unknown }) { return
{JSON.stringify(schema)}
; } From 067f3f087b47ac1596645621eedbf0dc49203bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:37:10 +0000 Subject: [PATCH 7/8] fix(plugin-charts): the chart's no-object predicate reads `data` and `bind` as BaseSchema members, with no second `bind` declaration (objectui#11605) `chartNeedsObject` read the record sources through a type literal that re-declared `bind` beside `BaseSchema`'s, which the `bind`-declared-once pin (`base-bind-declared.test.ts`, objectui#6357) refuses. It now reads `data` and `bind` as the `BaseSchema` members they are and `dataset`, this block's own key, by name with `Reflect.get`. The helper also moves above the `ObjectChartBlock` doc comment it had been inserted beneath, so that comment documents the block again. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- packages/plugin-charts/src/ObjectChart.tsx | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index e2fb9d8535..afc61e3d33 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -1656,6 +1656,19 @@ const OBJECT_CHART_DATA_SOURCE: ElementDataSourceMapping = { filter: true, }; +/** + * Whether a chart node has no record source but its object (objectui#11605): + * no inline `data`, no `dataset`, no `bind` path. `ObjectChart` fetches only by + * `objectName` or `dataset`, and draws inline or bound rows without either. + * + * `data` and `bind` are read as the `BaseSchema` members they are. `dataset` is + * this block's own key and no `BaseSchema` member, so it is read by name with + * `Reflect.get` rather than through a second type literal that would re-declare + * the base members beside it. + */ +const chartNeedsObject = (node: BaseSchema): boolean => + node.data == null && node.bind == null && Reflect.get(node, 'dataset') == null; + /** * Registry shell for `object-chart` — maps the spec's * `PageComponentSchema.dataSource` binding onto the keys {@link ObjectChart} @@ -1684,19 +1697,6 @@ const OBJECT_CHART_DATA_SOURCE: ElementDataSourceMapping = { * no new type is minted. Pinned by * `__tests__/ObjectChartBlock.props-8885.test.tsx`. */ -/** - * Whether a chart node has no record source but its object (objectui#11605): - * no inline `data`, no `dataset`, no `bind` path. `ObjectChart` fetches only by - * `objectName` or `dataset`, and draws inline or bound rows without either. - * - * Read through a weak type rather than off `BaseSchema` by name: `dataset` is - * this block's key, not a `BaseSchema` member. - */ -const chartNeedsObject = (node: BaseSchema): boolean => { - const sources = node as { data?: unknown; dataset?: unknown; bind?: unknown }; - return sources.data == null && sources.dataset == null && sources.bind == null; -}; - export const ObjectChartBlock = elementDataSourceBlock( (props: Omit & { schema: BaseSchema }) => ( Date: Sun, 4 Oct 2026 12:47:46 +0000 Subject: [PATCH 8/8] fix(plugin-form): an object-form whose sections are fully inline is a collector, not a node that names no object (objectui#11605) The `object-form` / `view:form` opt-out read non-empty `customFields` only. `ObjectForm` dispatches the `tabbed`, `wizard`, `split`, `drawer` and `modal` variants to renderers that also treat sections whose every field is an inline runtime `FormField` as an inline field source (objectui#10254), so such a node with no object and no binding had the "no object named" hint painted over a working collector. The predicate is now `!hasInlineFieldSource(schema)`, the shared helper those renderers use. The neither-hint pin gains one row per sectioned variant on both tags, red before this change; the published `objectName` description, the plugin-form changeset and the binding doc name inline sections beside `customFields`. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 --- .../11605-plugin-form-objectname-optional.md | 10 +++--- .../objectname-neither-hint-11605.test.tsx | 36 +++++++++++++++++-- content/docs/guide/data-source.md | 5 +-- packages/plugin-form/src/index.tsx | 12 ++++--- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.changeset/11605-plugin-form-objectname-optional.md b/.changeset/11605-plugin-form-objectname-optional.md index f1016b6b07..1ae0ac887f 100644 --- a/.changeset/11605-plugin-form-objectname-optional.md +++ b/.changeset/11605-plugin-form-objectname-optional.md @@ -23,7 +23,9 @@ or `object-master-detail-form` node that names its object through names its object in neither place also compiles now, and shows "No object named: set objectName or dataSource.object." where it used to draw a field-less card, a public form that could not submit, or an empty parent form. An `object-form` -with inline `customFields` shows no hint and renders as before. `formId` on -`embeddable-form` and `details` on `object-master-detail-form` are still -required. The published `objectName` inputs now carry a description that says the -binding can supply them. +or `view:form` whose fields are declared inline shows no hint and renders as +before: non-empty `customFields`, or `sections` whose every field is an inline +field, the target-less collector the `tabbed`, `wizard`, `split`, `drawer` and +`modal` variants render. `formId` on `embeddable-form` and `details` on +`object-master-detail-form` are still required. The published `objectName` +inputs now carry a description that says the binding can supply them. diff --git a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx index 24b41bf6a8..71688da3a1 100644 --- a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx +++ b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx @@ -31,7 +31,10 @@ * 4. Control, where the block has another record source: the neither node with * that source draws no hint, so the hint is not painted over a block that * is working. `object-kanban` has two such rows: inline `data`, and lanes - * that carry their own `cards`. + * that carry their own `cards`. `object-form` and `view:form` have two + * kinds: inline `customFields`, and, for each sectioned `formType` + * (`tabbed`, `wizard`, `split`, `drawer`, `modal`), sections whose every + * field is inline. */ import { describe, it, expect } from 'vitest'; @@ -142,14 +145,31 @@ async function mount(schema: Record) { } const html = view.container.innerHTML; const text = view.container.textContent ?? ''; + // The drawer and modal form variants render into a portal, outside the + // container, so the body is read too. + const bodyText = document.body.textContent ?? ''; try { view.unmount(); } catch { /* teardown is not the subject */ } - return { html, text, calls, hasHint: (testId: string) => html.includes(`data-testid="${testId}-no-object"`) }; + return { html, text, bodyText, calls, hasHint: (testId: string) => html.includes(`data-testid="${testId}-no-object"`) }; } +/** + * Sections whose every field is an inline runtime `FormField`: the sectioned + * form variants read them as an inline field source, a target-less collector + * whose `onSuccess` is the write (objectui#10254, `hasInlineFieldSource`). + */ +const INLINE_SECTIONS = [{ label: 'Contact', fields: [{ name: 'email', label: 'Email address', type: 'text' }] }]; + +/** The `formType`s `ObjectForm` dispatches to a sectioned renderer of its own. */ +const SECTIONED_VARIANTS = ['tabbed', 'wizard', 'split', 'drawer', 'modal'] as const; + +const SECTIONED_ROWS = (['object-form', 'view:form'] as const).flatMap((type) => + SECTIONED_VARIANTS.map((formType) => ({ type, formType })), +); + describe('objectui#11605 — a node naming its object in neither place shows the no-object hint', () => { it.each(MEMBERS)('$type — the neither node draws the hint and fetches nothing', async ({ type, testId, base }) => { const r = await mount({ type, ...base }); @@ -182,6 +202,18 @@ describe('objectui#11605 — a node naming its object in neither place shows the }, ); + it.each(SECTIONED_ROWS)( + '$type formType $formType — control: fully-inline sections are a record source, the collector renders, no hint', + async ({ type, formType }) => { + // No `objectName`, no binding and no `customFields`: the inline sections + // ARE the form, so the hint must not be painted over it. + const r = await mount({ type, formType, sections: INLINE_SECTIONS }); + expect(r.html).not.toContain('failed to render'); + expect(r.hasHint('object-form'), `<${type} formType=${formType}> drew the no-object hint over an inline collector`).toBe(false); + expect(r.bodyText).toContain('Email address'); + }, + ); + it('object-kanban — control: lanes that carry their own cards are a record source, no hint', async () => { // A static board: the board keeps a lane's own `cards` when it merges rows // into its lanes, so this node draws cards with no object and no `data`. diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index c82f0578a8..121f987730 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -299,8 +299,9 @@ it. A node that names its object in neither place renders a short hint, "No obje named: set objectName or dataSource.object.", in place of an empty list, board, form, chart, metric or pivot. `object-grid` shows its own "Object name required for data fetching" error instead. A block with another record source (inline `data` -rows, a form's `customFields`, a chart's `dataset`, a `bind` path, or a metric's -`fallbackValue`) draws from that source and shows no hint. +rows, a form's inline fields — `customFields`, or `sections` whose every field is +inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws +from that source and shows no hint. The two `element:*` rows keep their configuration in the node's `properties` bag, so the binding does not land on a schema key there: each reads it directly, and diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 69df173d5b..b730e588d5 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -17,6 +17,7 @@ import { } from '@object-ui/react'; import type { DataSource } from '@object-ui/types'; import { ObjectForm } from './ObjectForm'; +import { hasInlineFieldSource } from './submitTarget'; export { ObjectForm }; export type { ObjectFormComponentProps } from './ObjectForm'; @@ -218,9 +219,12 @@ const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = elem } noDataSourceMessage={noDataSourceMessage('object-form', schema?.objectName)} // A form that names its object in neither place (objectui#11605) drew a - // field-less card with Cancel and Update buttons. Inline `customFields` - // is again the one escape hatch. - requiresObject={!(schema?.customFields?.length > 0)} + // field-less card with Cancel and Update buttons. A form whose fields are + // declared inline is a target-less collector and needs no object: the + // shared `hasInlineFieldSource` answers that for every variant, both + // non-empty `customFields` and the sectioned variants' fully-inline + // `sections` (objectui#10254). + requiresObject={!hasInlineFieldSource(schema)} > {(bound) => } @@ -240,7 +244,7 @@ const OBJECT_FORM_OBJECT_NAME_INPUT: ComponentInput = { name: 'objectName', type: 'string', description: - 'Object this form creates or edits. 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, and no inline `customFields`, the form shows a hint naming this key instead of a form with no fields.', + 'Object this form creates or edits. 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, and no inline fields (non-empty `customFields`, or `sections` whose every field is inline), the form shows a hint naming this key instead of a form with no fields.', }; ComponentRegistry.register('object-form', ObjectFormRenderer, {