Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/6575-data-table-bind-diagnostic.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -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, '../../../..');
Expand DownExpand Up@@ -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(
<SchemaRendererProvider dataSource={dataSource}>
Expand DownExpand Up@@ -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) => {
Expand All@@ -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([]);
},
);

Expand All@@ -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`');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
@@ -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('()');
});
});
23 changes: 23 additions & 0 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading