diff --git a/.changeset/6575-data-table-bind-diagnostic.md b/.changeset/6575-data-table-bind-diagnostic.md new file mode 100644 index 0000000000..689206bd7c --- /dev/null +++ b/.changeset/6575-data-table-bind-diagnostic.md @@ -0,0 +1,51 @@ +--- +'@object-ui/components': patch +'@object-ui/plugin-dashboard': patch +--- + +A `bind` authored on a `data-table` is now diagnosed at render instead of ignored in +silence (objectui#6575). + +`bind` is the data-scope vocabulary: a path string resolved by `useDataScope()`. +`list`, `tree-view` and the `object-*` plugin widgets read it. `data-table` does not +— it takes its rows from an inline `data` array on the node and never calls the hook. +A `bind` on a `data-table` was nevertheless accepted by every gate: the TS side via +`BaseSchema`'s index signature, the zod side via `BaseSchema` being `.passthrough()`, +which `DataTableSchema.extend(…)` inherits. Nothing read it at render, so the author +got a table drawing a correct-looking header over the "No results found" empty state, +with no error and no warning — a success receipt for a disagreement between the +author and the renderer, and the hardest failure shape for a human or an AI author to +self-check. + +The platform was already paying for this in teaching rather than in diagnostics: +`skills/objectui/rules/protocol.md` documents the pothole verbatim and a pin test +locks the behaviour. The warning now also reaches the console, where the author who +did not read the docs is standing: + +> `bind: 'customers'` is ignored: data-table does not read `bind`; it reads its rows +> from the inline `data` array on the node. This node has no inline rows, so the +> table renders its header over an empty body. + +It names the node's address, the path that was spelled, and the way out. The +consequence clause is measured rather than asserted: a node carrying BOTH `data` and +`bind` is not empty, and is told that its rows came from `data` and its `bind` +contributed nothing. + +**No behaviour change.** `data-table` still does not read `bind`, and per the +2026-08-27 ruling it must not start — making it a `useDataScope` reader is a separate +published-surface question needing its own ruling, including a `data`-vs-`bind` +precedence. Refusing the key at parse stays blocked on the `.passthrough()` ceiling +(objectui#5155 / objectui#6269). The trap stops being silent; it does not stop being +a trap. The channel is the one `plugin-grid`'s `columnSpellingDiagnostics.ts` already +uses for this exact shape of failure — a pure describe function, a `useEffect` keyed +on the schema slice, one `console.warn`, no NODE_ENV branch. + +`ObjectDataTable` (`@object-ui/plugin-dashboard`) stops forwarding a `bind` it has +already consumed. It resolves the binding itself via `useDataScope(schema.bind)` and +then delegated with `{ ...schema, type: 'data-table', … }`, which handed the spent +key to a component that cannot read one. Without this, a correctly authored and +published-guide-taught `object-data-table` would have tripped the new diagnostic on +every render, over rows that were on screen precisely because its `bind` had been +honoured. The key is stopped where it was spent — the same shape its sibling +`DashboardGridLayout` already uses for `data`. Nothing else about that delegation +moved, and the bound rows still arrive. diff --git a/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx b/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx index be8ae67615..ee25891fe1 100644 --- a/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx +++ b/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx @@ -47,7 +47,7 @@ * whichever way that one lands — nothing below asserts a column key spelling. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; import fs from 'node:fs'; @@ -61,6 +61,7 @@ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; // file lives INSIDE `@object-ui/components`, and the bare specifier would be a // package self-import (`scripts/check-package-self-import.mjs`). import '../renderers'; +import { DATA_TABLE_BIND_DIAGNOSTIC_PREFIX } from '../renderers/complex/dataTableBindDiagnostic'; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, '../../../..'); @@ -115,6 +116,21 @@ function bodyCells(): string[] { return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim()); } +/** + * objectui#6575 — every `[ObjectUI] DataTable bind:` line this render emitted. + * + * Filtered by the diagnostic's own prefix rather than by call count: these + * renders go through the REAL `SchemaRenderer` and the real registry, so an + * unrelated warning from some other component must not be able to satisfy — + * or break — an assertion about this one. + */ +function bindWarnings(): string[] { + const spy = console.warn as unknown as { mock?: { calls: unknown[][] } }; + return (spy.mock?.calls ?? []) + .map((args) => String(args[0])) + .filter((line) => line.startsWith(DATA_TABLE_BIND_DIAGNOSTIC_PREFIX)); +} + function renderNode(schema: unknown, dataSource: unknown) { return render( @@ -156,12 +172,23 @@ describe('skill guides — no `data-table` example is bound with `bind` (#5126, }); }); -describe('skill guides — the taught `data-table` form renders rows (#5126)', () => { +describe('skill guides — the taught `data-table` form renders rows (#5126, #6575)', () => { // A decoy dataSource: it holds exactly the path the retired example bound to. // Rows appearing while this is in scope proves they came from the node's // inline `data`, not from the provider. const DECOY = { customers: [{ name: 'Should Not Appear', email: 'decoy@example.com' }] }; + // objectui#6575 added a render-time diagnostic on the ignored `bind`. Both + // legs below now read it, in opposite directions, off the SAME renders that + // already pin the behaviour — so "the table is still empty" and "the author + // is now told why" cannot drift apart into two trees. + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it.each(['skills/objectui/guides/schema-expressions.md', 'skills/objectui/guides/data-integration.md'] as const)( '%s: the inline-`data` table puts its rows on screen', (rel) => { @@ -179,6 +206,13 @@ describe('skill guides — the taught `data-table` form renders rows (#5126)', ( 'Grace Hopper', 'grace@example.com', ]); + + // objectui#6575, the SILENT direction. This node carries no `bind`, so + // the diagnostic must not fire — a warning that fires on every table is + // worse than no warning at all. The zero is a reading because the + // sibling test below finds a line through this same helper, on the same + // channel, one `bind` key apart. + expect(bindWarnings()).toEqual([]); }, ); @@ -198,6 +232,27 @@ describe('skill guides — the taught `data-table` form renders rows (#5126)', ( // not assumed: the bound array never reaches the renderer at all. expect(document.querySelectorAll('tbody tr')).toHaveLength(1); expect(bodyCells()).toEqual(['No results foundTry adjusting your filters or search query.']); + + // objectui#6575 — the trap stops being silent (maintainer ruling + // 2026-08-27, option A). THIS is the load-bearing half of the update: + // every assertion above passes identically against the tree before the + // diagnostic existed, so only the lines below can tell the two apart. + // + // Behaviour is unchanged and stays pinned above: the rows still do not + // arrive. What is new is that the author is told so. + const warnings = bindWarnings(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("`bind: 'customers'` is ignored"); + // The sentence the ruling names, matching what + // `skills/objectui/rules/protocol.md` already teaches. + expect(warnings[0]).toContain( + 'data-table does not read `bind`; it reads its rows from the inline `data` array on the node', + ); + // The consequence, measured rather than asserted: this table really is + // empty, and the message says so only because of that. + expect(warnings[0]).toContain('renders its header over an empty body'); + // And the way out. + expect(warnings[0]).toContain('Put the rows in `data`'); }); }); diff --git a/packages/components/src/renderers/complex/__tests__/data-table-bind-diagnostic.test.ts b/packages/components/src/renderers/complex/__tests__/data-table-bind-diagnostic.test.ts new file mode 100644 index 0000000000..bebdd656af --- /dev/null +++ b/packages/components/src/renderers/complex/__tests__/data-table-bind-diagnostic.test.ts @@ -0,0 +1,121 @@ +/** + * 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#6575 — the PURE half of the `bind`-is-ignored diagnostic: what it + * says, and the silence it has to keep. + * + * The rendered half (the warning actually reaching the console through the + * real `SchemaRenderer`, and NOT reaching it on a node without `bind`) is + * pinned in `src/__tests__/skill-guide-data-table-binding.test.tsx`, next to + * the behaviour assertions it has to stay consistent with. This file judges + * the message text, which is the part an author reads. + * + * Every zero below is paired with a positive control in the same query shape: + * "no message for X" is only a reading once "a message for Y" passes through + * the same call. + */ + +import { describe, it, expect } from 'vitest'; +import { + describeIgnoredBind, + hasAuthoredBind, + DATA_TABLE_BIND_DIAGNOSTIC_PREFIX, +} from '../dataTableBindDiagnostic'; + +const ADDRESS = { blockType: 'data-table', id: 'customers-table', caption: 'Customers' }; +const ROWS = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }]; + +describe('hasAuthoredBind — absence is `undefined`, and nothing else (#6575)', () => { + it('is false only for an omitted key', () => { + expect(hasAuthoredBind(undefined)).toBe(false); + // Positive control in the same shape: a written key is written. + expect(hasAuthoredBind('customers')).toBe(true); + }); + + it('counts the falsy values an author can actually type', () => { + // `null` and `''` are things someone WROTE. They bought nothing either, and + // a diagnostic that skipped them would be silent on the exact typo — an + // emptied-out binding — that looks most like a working one. + expect(hasAuthoredBind(null)).toBe(true); + expect(hasAuthoredBind('')).toBe(true); + expect(hasAuthoredBind(0)).toBe(true); + }); +}); + +describe('describeIgnoredBind — silence, and the control that earns it (#6575)', () => { + it('says nothing when no `bind` was authored', () => { + expect(describeIgnoredBind(undefined, ROWS, ADDRESS)).toBeNull(); + // The counter-probe: the SAME call with a `bind` does produce a message, + // so the null above is a verdict rather than a broken code path. + expect(describeIgnoredBind('customers', ROWS, ADDRESS)).not.toBeNull(); + }); + + it('stays silent on a table with rows and no `bind` — the common case', () => { + expect(describeIgnoredBind(undefined, [], ADDRESS)).toBeNull(); + expect(describeIgnoredBind(undefined, undefined, ADDRESS)).toBeNull(); + }); +}); + +describe('describeIgnoredBind — what the author is told (#6575)', () => { + it('names the address, the path, and the key that IS read', () => { + const message = describeIgnoredBind('customers', [], ADDRESS)!; + expect(message).toContain(DATA_TABLE_BIND_DIAGNOSTIC_PREFIX); + // The address: which node on the page, not merely "a data-table". + expect(message).toContain("data-table (id: 'customers-table', caption: 'Customers')"); + // The path the author spelled, quoted back at them. + expect(message).toContain("`bind: 'customers'` is ignored"); + // The sentence the maintainer ruling names, and the corpus already teaches + // in `skills/objectui/rules/protocol.md`. + expect(message).toContain( + 'data-table does not read `bind`; it reads its rows from the inline `data` array on the node', + ); + // The way out. A message that only reported the fault would leave the + // author exactly where the silence did. + expect(message).toContain('Put the rows in `data`'); + expect(message).toContain('`list`, `tree-view`, or an `object-*` widget'); + expect(message).toContain('objectui#6575'); + }); + + it('claims the empty body ONLY when the body is empty', () => { + const empty = describeIgnoredBind('customers', [], ADDRESS)!; + expect(empty).toContain('renders its header over an empty body'); + + // Both keys authored: the table is NOT empty, so the consequence sentence + // above would be a message asserting something it did not check. + const withRows = describeIgnoredBind('customers', ROWS, ADDRESS)!; + expect(withRows).not.toContain('empty body'); + expect(withRows).toContain('The 2 rows on screen come from `data`'); + expect(withRows).toContain('the `bind` contributes nothing'); + }); + + it('counts one row in the singular', () => { + expect(describeIgnoredBind('customers', [ROWS[0]], ADDRESS)!).toContain( + 'The 1 row on screen comes from `data`', + ); + }); + + it('treats a non-array `data` as no rows — the renderer already does', () => { + // `DataTableRenderer` resolves a provider-config object to `EMPTY_ROWS` + // before rendering, so the body really is empty here. + const message = describeIgnoredBind('customers', { provider: 'object' }, ADDRESS)!; + expect(message).toContain('renders its header over an empty body'); + }); + + it('quotes a non-string `bind` without pretending it was a path', () => { + expect(describeIgnoredBind(null, [], ADDRESS)!).toContain('`bind: null` is ignored'); + expect(describeIgnoredBind(42, [], ADDRESS)!).toContain('`bind: 42` is ignored'); + }); + + it('falls back to the block name when the node carries no id or caption', () => { + const message = describeIgnoredBind('customers', [], {})!; + expect(message).toContain(`${DATA_TABLE_BIND_DIAGNOSTIC_PREFIX} data-table —`); + // Not an empty parenthetical where the address should be. + expect(message).not.toContain('()'); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index d8baf51ea6..d0a32a0a67 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -11,6 +11,7 @@ import React, { useState, useMemo, useRef, useEffect, useLayoutEffect } from 're import { cn } from '../../lib/utils'; import { resolveIcon } from '../action/resolve-icon'; import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring'; +import { describeIgnoredBind } from './dataTableBindDiagnostic'; import { ComponentRegistry, compareSortValues, evalRowPredicate, getSortValue } from '@object-ui/core'; import type { DataTableSchema, TableSortItem, TableColumnType } from '@object-ui/types'; import { SchemaRenderer, useRowPredicate, usePredicateScope } from '@object-ui/react'; @@ -729,6 +730,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { showAddRow = false, borderless = false, disableInnerScroll = false, + // Read ONLY to diagnose it. `data-table` does not resolve `bind` and the + // objectui#6575 ruling is explicit that it must not start — see + // `dataTableBindDiagnostic.ts`. + bind: authoredBind, } = schema; // 'single' caps the selection at one row (replace-on-select) and drops the @@ -778,6 +783,24 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // every downstream memo on each render (objectui#4618). const data = Array.isArray(rawData) ? rawData : EMPTY_ROWS; + // objectui#6575 — say out loud that an authored `bind` was ignored. + // + // Channel: the one `plugin-grid` already uses for "you declared it, the + // renderer dropped it" — a `useEffect` keyed on the schema slice and one + // `console.warn` (see `columnSpellingDiagnostics.ts`) — rather than a second, + // differently-shaped one. `data` is in the key because the message's + // consequence clause is measured against the rows actually resolved. + const bindDiagnosticBlockType = (schema as { type?: unknown }).type; + const bindDiagnosticId = (schema as { id?: unknown }).id; + useEffect(() => { + const message = describeIgnoredBind(authoredBind, data, { + blockType: bindDiagnosticBlockType, + id: bindDiagnosticId, + caption, + }); + if (message) console.warn(message); + }, [authoredBind, data, bindDiagnosticBlockType, bindDiagnosticId, caption]); + // The adapter reads the column keys `TableColumn` DECLARES. The `label` // alias is gone (objectui#5351); the `name` alias is HELD, and the hold is // deliberate and documented rather than an oversight. diff --git a/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts b/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts new file mode 100644 index 0000000000..c14c806ca6 --- /dev/null +++ b/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts @@ -0,0 +1,144 @@ +/** + * 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. + */ + +/** + * The diagnostic that says out loud what `data-table` has always done silently + * with an authored `bind` — nothing (objectui#6575, maintainer ruling + * 2026-08-27, option A: 「同意」). + * + * ## The defect this names + * + * `bind` is the data-scope binding vocabulary: a path string resolved by + * `useDataScope()`. `list`, `tree-view` and the `object-*` plugin widgets read + * it. `DataTableRenderer` does NOT — it takes its rows from `data: rawData = + * EMPTY_ROWS` off the node, and the file calls no such hook. + * + * A `bind` on a `data-table` is nevertheless accepted by every gate: the TS + * side by `BaseSchema`'s `[key: string]: any`, the zod side by `BaseSchema` + * being `.passthrough()` (which `DataTableSchema.extend(…)` inherits). So the + * author gets a table drawing a correct-looking header over the "No results + * found" empty state, with no error, no warning and no diagnostic — the + * hardest failure shape for a human OR an AI author to self-check, because a + * rendered header reads as a success receipt. + * + * The platform was already paying a teaching cost for it rather than a + * diagnostic cost: `skills/objectui/rules/protocol.md` documents the pothole + * verbatim, and `skill-guide-data-table-binding.test.tsx` pins the behaviour. + * This module moves the warning from the docs to the console, where the author + * who did not read the docs is standing. + * + * ## What it deliberately does NOT do + * + * It changes NO behaviour. `data-table` still does not read `bind`, and the + * ruling is explicit that it must not start: making it a `useDataScope` reader + * (option B) is a separate published-surface question needing its own ruling, + * including a `data`-vs-`bind` precedence. Refusing the key at parse (option C) + * stays blocked on the `.passthrough()` ceiling (objectui#5155 / objectui#6269). + * So the trap stops being silent; it does not stop being a trap. + * + * ## Why a console warning, and only a console warning + * + * The same channel `plugin-grid`'s `columnSpellingDiagnostics.ts` uses for the + * identical shape of failure ("you declared something and the renderer dropped + * it"): a pure `describe…` function returning `string | null`, called from a + * `useEffect` keyed on the schema slice, one `console.warn`, no NODE_ENV + * branch. Deliberately the SAME shape rather than a second, differently-shaped + * one next to it. Rendering an in-table message instead would be user-facing + * copy needing all ten locale packs in `@object-ui/i18n`; a throw would take + * the surrounding page down for a defect that costs one table. + * + * The rate limit is the `useEffect` key, and that is enough HERE for a reason + * `visibilityDiagnostic.ts` does not have available: a node gate is evaluated + * once per row, so it needs a module-level dedupe `Set` to keep one authoring + * bug from printing N lines. A `data-table` is one node rendered once — the + * effect key is one line per mount per distinct `bind`, which is already the + * "one line per distinct authoring bug" ceiling that `Set` exists to buy. + * + * ## The message never asserts something it did not check + * + * A node can carry BOTH an inline `data` array and a `bind`. That table is not + * empty, so the "header over an empty body" consequence would be false there — + * and a diagnostic that overstates its own consequence teaches authors to + * distrust it. The two cases get two different consequence clauses, decided by + * looking at the rows the renderer actually resolved. + */ + +/** Prefix for every line this module emits — the handle tests and greps hold. */ +export const DATA_TABLE_BIND_DIAGNOSTIC_PREFIX = '[ObjectUI] DataTable bind:'; + +/** The key `data-table` really reads its rows from. */ +export const DECLARED_ROWS_KEY = 'data'; + +/** Where the offending node lives, for the first line of the message. */ +export interface DataTableBindAddress { + /** The schema node's `type` — `data-table`, or an alias that routes here. */ + blockType?: unknown; + /** The node's `id`, when it has one. */ + id?: unknown; + /** The table's authored caption — often the only human-readable name. */ + caption?: unknown; +} + +function quote(value: unknown): string { + return typeof value === 'string' ? `'${value}'` : String(value); +} + +function describeAddress({ blockType, id, caption }: DataTableBindAddress): string { + const block = typeof blockType === 'string' && blockType.length > 0 ? blockType : 'data-table'; + const parts: string[] = []; + if (typeof id === 'string' && id.length > 0) parts.push(`id: '${id}'`); + if (typeof caption === 'string' && caption.length > 0) parts.push(`caption: '${caption}'`); + return parts.length > 0 ? `${block} (${parts.join(', ')})` : block; +} + +/** + * Was a `bind` authored on this node? + * + * `undefined` is absence — a destructuring default or an omitted key. Every + * other value, including `null` and the empty string, is something the author + * WROTE, and writing it bought nothing. Exported so the renderer's effect key + * and this judgement cannot drift apart: one predicate, two readers. + */ +export function hasAuthoredBind(bind: unknown): boolean { + return bind !== undefined; +} + +/** + * The message for a `data-table` node carrying a `bind`, or `null` when there + * is nothing to say. + * + * `rows` is what the renderer resolved for the body — passed in rather than + * re-derived, so the consequence sentence is measured against the same array + * the reader is looking at. + * + * Naming the ADDRESS is the point: which node, which path it spells, what the + * renderer did instead, and what to write to get the rows on screen. A message + * that only said something went wrong would leave the author where the silence + * did. + */ +export function describeIgnoredBind( + bind: unknown, + rows: unknown, + address: DataTableBindAddress, +): string | null { + if (!hasAuthoredBind(bind)) return null; + + const rowCount = Array.isArray(rows) ? rows.length : 0; + const consequence = + rowCount === 0 + ? 'This node has no inline rows, so the table renders its header over an empty body' + : `The ${rowCount} ${rowCount === 1 ? 'row' : 'rows'} on screen ` + + `${rowCount === 1 ? 'comes' : 'come'} from \`${DECLARED_ROWS_KEY}\`; ` + + 'the `bind` contributes nothing'; + + return `${DATA_TABLE_BIND_DIAGNOSTIC_PREFIX} ${describeAddress(address)} — ` + + `\`bind: ${quote(bind)}\` is ignored: data-table does not read \`bind\`; it reads its rows ` + + `from the inline \`${DECLARED_ROWS_KEY}\` array on the node. ${consequence}.\n` + + ` Put the rows in \`${DECLARED_ROWS_KEY}\`, or author a component that does read \`bind\` ` + + '(`list`, `tree-view`, or an `object-*` widget — they call `useDataScope`). (objectui#6575)'; +} diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index 60ea37191d..90cb9689b5 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -884,8 +884,18 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // Honor an author-supplied onRowClick; otherwise wire the drill-to-record // handler when drill-down is enabled. The base data-table guards against // firing on interactive cells (buttons / menus / dialogs). + // + // objectui#6575 — `bind` is CONSUMED here, not passed through: `boundData = + // useDataScope(schema.bind)` resolved it above and `finalData` below IS that + // result. Spreading it onward handed the key to `data-table`, which reads no + // `bind` at all, so a correctly bound (and published-guide-taught) widget + // tripped that card's ignored-`bind` diagnostic on every render — a warning + // over rows that were on screen BECAUSE the bind had been honoured. Stop the + // key where it was spent, the same shape `DashboardGridLayout` already uses + // for `data`. Pinned by `ObjectDataTable.bindNotForwarded-6575.test.tsx`. + const { bind: _consumedBind, ...schemaWithoutBind } = schema; const tableSchema = { - ...schema, + ...schemaWithoutBind, type: 'data-table', data: finalData, columns: derivedColumns, diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx new file mode 100644 index 0000000000..02b89783ae --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx @@ -0,0 +1,147 @@ +/** + * 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#6575 — `ObjectDataTable` must not forward a `bind` it has already + * consumed into the `data-table` node it delegates to. + * + * ## Why this test exists in THIS package + * + * #6575 makes `data-table` warn when a node carries a `bind`, because + * `data-table` does not read one — that is the trap the card closes. + * `ObjectDataTable` DOES read `bind`: `const boundData = useDataScope(schema.bind)` + * resolves the rows, and the widget then builds its inner node as + * `{ ...schema, type: 'data-table', data: finalData, … }`. That spread carried + * the already-consumed `bind` straight through to a component that cannot read + * it. + * + * So a correctly authored `object-data-table` bound with `bind` — the form the + * published guides TEACH, and which `skill-guide-data-table-binding.test.tsx` + * pins as a genuine reader — would have printed "your `bind` is ignored" on + * every render, over rows that were on screen precisely BECAUSE the `bind` had + * been honoured. A diagnostic that fires on working, taught code is worse than + * the silence it replaces; `plugin-grid`'s `columnSpellingDiagnostics.ts` says + * so in as many words about its own predicate. + * + * The fix is on the PRODUCER rather than as a tolerance carve-out in the + * consumer: a key this widget has consumed is this widget's to stop. Its own + * sibling `DashboardGridLayout` already forwards in exactly that shape — + * `const { data: _data, ...restOptions } = options`. + * + * ## What is measured, and the legs that keep each other honest + * + * The node handed to `SchemaRenderer`, captured at the delegation seam (the + * same `vi.mock` shape the sibling suites in this directory use, so this does + * not depend on the component registry). Three legs, because any one alone is + * satisfied by a wrong fix: + * + * 1. the forwarded node carries no `bind` — the fix; + * 2. the rows still arrive — so the `bind` was CONSUMED, not deleted + * (leg 1 alone passes if the binding stops working entirely); + * 3. an unrelated authored key still comes through — so the spread is still + * a spread (legs 1-2 alone pass if the whole `...schema` were dropped). + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import React from 'react'; + +const captured = vi.hoisted(() => ({ schemas: [] as any[] })); + +// Only `SchemaRenderer` is stubbed. `SchemaRendererProvider` and `useDataScope` +// stay REAL — the binding under test has to resolve for real, or leg 2 below +// would be measuring the stub instead of the widget. +vi.mock('@object-ui/react', async () => { + const actual: any = await vi.importActual('@object-ui/react'); + return { + ...actual, + SchemaRenderer: ({ schema }: any) => { + captured.schemas.push(schema); + return ( +
+ {(schema.data ?? []).map((row: any, i: number) => ( + {String(row.name)} + ))} +
+ ); + }, + }; +}); + +import { ObjectDataTable } from '../ObjectDataTable'; + +afterEach(() => { + cleanup(); + captured.schemas.length = 0; +}); + +const I18N_CONFIG = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +const ROWS = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }]; + +/** + * The authored form: an `object-data-table` bound with `bind`, as the guides + * teach it. `caption` is along for leg 3 — an ordinary authored key with no + * part in this fix, which must still survive the spread. + */ +const BOUND_SCHEMA = { + type: 'object-data-table', + bind: 'customers', + caption: 'Customers', + columns: ['name'], +} as any; + +function renderBound() { + return render( + + + + + , + ); +} + +function innerNode(): any { + // Counter-probe: the seam really did capture a delegation. Assertions over an + // empty capture list would pass vacuously and prove nothing. + expect(captured.schemas.length).toBeGreaterThan(0); + const inner = captured.schemas[captured.schemas.length - 1]; + expect(inner.type).toBe('data-table'); + return inner; +} + +describe('ObjectDataTable — a consumed `bind` is not forwarded to data-table (#6575)', () => { + it('leg 1: hands the inner data-table node no `bind` at all', () => { + renderBound(); + const inner = innerNode(); + + // `in`, not a truthiness check: `bind: undefined` present on the node is + // still a key, and the #6575 predicate is about what was written. + expect('bind' in inner).toBe(false); + expect(inner.bind).toBeUndefined(); + }); + + it('leg 2: still resolves the bound rows — the `bind` is consumed, not dropped', () => { + const { getByTestId } = renderBound(); + const inner = innerNode(); + + expect(inner.data).toEqual(ROWS); + expect(getByTestId('table').textContent).toContain('Ada Lovelace'); + expect(getByTestId('table').textContent).toContain('Grace Hopper'); + }); + + it('leg 3: unrelated authored keys still come through the spread', () => { + renderBound(); + const inner = innerNode(); + + // `bind` is stopped BY NAME. Nothing else about the delegation moved. + expect(inner.caption).toBe('Customers'); + }); +});