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
36 changes: 36 additions & 0 deletions .changeset/data-table-header-declared-key-5351.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/core': minor
'@object-ui/components': minor
'@object-ui/plugin-dashboard': minor
'@object-ui/plugin-detail': minor
---

`data-table` reads the declared `header`; the producers translate `label` into it.

`TableColumn` declares `header: string` and does not declare `label`. The
renderer's column normalization nonetheless read `header: col.header || col.label`,
so the same key had one spelling the type admits and one only the runtime did.
That alias is gone (objectui#5351), and the translation it used to perform happens
once at each producer instead: metadata vocabulary in, adapter vocabulary out.

**This narrows what `data-table` accepts, so read this if you author `data-table`
nodes by hand.** A column spelled `{ label: 'Stage', accessorKey: 'stage' }` on a
directly authored `data-table` now renders a **headerless** column over live
cells. Spell it `header` — the key `TableColumn` has always declared. Columns
reaching `data-table` through `object-data-table`, `object-grid` or a related
list are unaffected: those producers resolve `header` for you from the spec's
`ListColumnSchema.label`, so every spelling they accepted before they still
accept.

`@object-ui/core` gains `columnHeader()` alongside `columnIdentity()` — the reader
producers use to cross that boundary. It is adapter-first (`header` wins over
`label`), so an author who addressed the table directly is never overwritten.

`object-data-table` also gains a fix from the same move: a column carrying a
`label` used to render a **blank** header there even while the alias existed,
because the widget's field-meta enrichment overwrote the authored `label` before
the adapter ever saw it. `{ field: 'stage', label: 'Stage' }` now renders "Stage".

The sibling `accessorKey: col.accessorKey || col.name` alias is **unchanged** here
and still resolves. Retiring it is objectui#5120's remaining step, which is
gated on two published skill guides that teach that spelling.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
/**
* 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.
*/

/**
* `data-table` reads the DECLARED `header`, not the undeclared `label`
* (objectui#5351).
*
* `TableColumn` (`packages/types/src/data-display.ts`) declares `header: string`
* and `accessorKey: string`. It declares neither `label` nor `name`. The
* adapter's column normalization nonetheless read
*
* header: col.header || col.label
* accessorKey: col.accessorKey || col.name
*
* so one key had two spellings — one the type admits, one only the runtime did.
* That second de-facto contract is what AGENTS.md #0.1 forbids, and the
* maintainer ruling of 2026-08-20 settled the direction for the whole family:
* retire the consumer-side alias, unify the producers. `data-table` is an
* ADAPTER; `column-identity.ts` names its keys `TABLE_ADAPTER_COLUMN_KEY` /
* `TABLE_ADAPTER_HEADER_KEY` and holds the metadata fold away from them on
* purpose. Metadata vocabulary in, adapter vocabulary out; one translation, one
* place — and that place is each producer, never here.
*
* SCOPE. The `label` alias retires here; the `name` alias is HELD, and the last
* describe below pins it as still-read so the hold cannot be mistaken for the
* retirement having happened. Two published skill guides teach a directly
* authored `data-table` whose columns are spelled `{ name, label }`, and
* `skill-guide-data-table-binding.test.tsx` renders those blocks straight out of
* the guide files — so `name` cannot retire until the instruction corpus moves.
* That is objectui#5120's remaining step and is nobody's to take unbidden:
* `skills/**` is a customer-published surface with its own owning seat.
*
* The two aliases were DIFFERENT failure classes, which is why the cards were
* filed apart: an unresolved `accessorKey` gives blank cells under a live
* header, while an unresolved `header` gives a headerless column over live
* cells. Neither is dropped and neither throws — that legibility is pinned
* below too, because it is exactly what objectui#5349 measures against.
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import '../data-table';

const ROWS = [
{ id: '1', stage: 'Won' },
{ id: '2', stage: 'Lost' },
];

function renderTable(columns: unknown[]) {
const DataTable = ComponentRegistry.get('data-table') as any;
if (!DataTable) throw new Error('data-table not registered');
return render(
<DataTable schema={{ type: 'data-table', data: ROWS, columns, pagination: false, searchable: false }} />,
);
}

/** Every rendered header cell's text, in order. */
const headers = () =>
Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim());

/** Every rendered body cell's text, row-major. */
const bodyCells = () =>
Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());

describe('data-table columns — the declared keys render (unchanged)', () => {
it('renders a column authored the declared way', () => {
renderTable([{ header: 'Stage', accessorKey: 'stage' }]);
expect(headers()).toEqual(['Stage']);
expect(bodyCells()).toEqual(['Won', 'Lost']);
expect(screen.getByText('Won')).toBeInTheDocument();
});
});

describe('data-table columns — the `name` alias is still read, and that is a HOLD (#5120)', () => {
it('still resolves an accessor from the undeclared `name`', () => {
// NOT an endorsement — a receipt. The 2026-08-20 ruling retires this limb;
// what stops it today is that two published skill guides teach it and
// `skill-guide-data-table-binding.test.tsx` renders their bytes. Pinning the
// CURRENT behaviour means the day the guides move, this test goes red and
// names itself as the thing to delete, instead of the retirement quietly
// never happening.
renderTable([{ header: 'Stage', name: 'stage' }]);
expect(bodyCells()).toEqual(['Won', 'Lost']);
});

it('keeps an authored `accessorKey` ahead of a divergent `name`', () => {
// Precedence, unchanged and load bearing: columns can arrive in the table
// library's own shape, and those must not be second-guessed.
renderTable([{ header: 'Stage', accessorKey: 'stage', name: 'nonsense' }]);
expect(bodyCells()).toEqual(['Won', 'Lost']);
});
});

describe('data-table columns — the undeclared `label` alias is retired (#5351)', () => {
it('does not resolve a header from `label`', () => {
// A DIFFERENT failure class from #5120's, which is why the card was filed
// separately: the cells are fine and the HEADER is what goes missing.
renderTable([{ label: 'Stage', accessorKey: 'stage' }]);
expect(headers()).toEqual(['']);
expect(bodyCells()).toEqual(['Won', 'Lost']);
});

it('keeps an authored `header` winning over a divergent `label`', () => {
renderTable([{ header: 'Stage', label: 'nonsense', accessorKey: 'stage' }]);
expect(headers()).toEqual(['Stage']);
});

it('LEGIBILITY: a headerless column still renders its cells and its neighbour', () => {
renderTable([
{ label: 'Stage', accessorKey: 'stage' },
{ header: 'Id', accessorKey: 'id' },
]);
expect(headers()).toEqual(['', 'Id']);
expect(bodyCells()).toEqual(['Won', '1', 'Lost', '2']);
});
});

describe('data-table columns — the auto-width pass reads the same header key', () => {
it('sizes from the declared `header`, not from `label`', () => {
// The width pass is a SECOND read of the same columns, a few lines below
// the first. If the two ever spell a key differently the table measures one
// set of columns and renders another, so they are pinned to move together.
// A column's estimated width starts from its HEADER length, so a long
// `label` that the adapter no longer reads contributes nothing and the
// column falls to the 80px floor, while its declared twin does not.
const DataTable = ComponentRegistry.get('data-table') as any;
const LONG = 'A Really Quite Long Column Header';
render(
<DataTable
schema={{
type: 'data-table',
data: [{ id: '1', a: 'x', b: 'x' }],
pagination: false,
searchable: false,
columns: [
{ label: LONG, accessorKey: 'a' },
{ header: LONG, accessorKey: 'b' },
],
}}
/>,
);
const ths = Array.from(document.querySelectorAll('thead th')) as HTMLElement[];
expect(ths).toHaveLength(2);
expect(ths[0].style.width).toBe('80px');
expect(parseInt(ths[1].style.width, 10)).toBeGreaterThan(80);
});
});
41 changes: 37 additions & 4 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -769,20 +769,53 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
// every downstream memo on each render (objectui#4618).
const data = Array.isArray(rawData) ? rawData : EMPTY_ROWS;

// Normalize columns to support legacy keys (label/name) from existing JSONs
// 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.
//
// These two lines used to normalize each column as
// `header: col.header || col.label` and
// `accessorKey: col.accessorKey || col.name` — two undeclared aliases for two
// declared keys. `TableColumn` (`packages/types/src/data-display.ts`) declares
// `header: string` and `accessorKey: string`; it declares neither. So the
// declared surface admitted one spelling while the runtime admitted two, which
// is the second de-facto contract AGENTS.md #0.1 forbids, and the 2026-08-20
// ruling settled the direction for the whole family: retire the consumer-side
// alias, unify the producers.
//
// `header` has retired. Where its translation went — `columnHeader` in
// `@object-ui/core`, called by each producer before delivery:
// `ObjectDataTable.normalizeColumns` (`@object-ui/plugin-dashboard`)
// `RelatedList.normalizeColumn` (`@object-ui/plugin-detail`)
// `ObjectGrid.generateColumns` (`@object-ui/plugin-grid`, since #5068)
// Metadata vocabulary in, adapter vocabulary out; one translation, one place.
//
// `accessorKey || col.name` STAYS, pending objectui#5120's remaining step. It
// is not that the producers still need it — all three resolve `accessorKey`
// themselves, measured — but that two PUBLISHED skill guides teach a directly
// authored `data-table` whose columns are spelled `{ name, label }`:
// skills/objectui/guides/data-integration.md
// skills/objectui/guides/schema-expressions.md
// `skill-guide-data-table-binding.test.tsx` lifts those blocks out of the real
// files at run time and renders them, so retiring `name` here turns that gate
// red until the guides move. Retiring the runtime ahead of the instruction
// would leave the platform refusing a spelling it still ships, and the failure
// it teaches into is the illegible one below: a header over blank cells.
const initialColumns = useMemo(() => {
return rawColumns.map((col: any) => ({
...col,
header: col.header || col.label,
accessorKey: col.accessorKey || col.name
accessorKey: col.accessorKey || col.name,
}));
}, [rawColumns]);

// Auto-size columns: estimate width from header and data content for columns without explicit widths
const autoSizedWidths = useMemo(() => {
const widths: Record<string, number> = {};
// Spelled identically to `initialColumns` above — the auto-width pass must
// measure the SAME columns the table renders, so the two reads move
// together (objectui#5351 retired `header`'s alias; `name`'s is held).
const cols = rawColumns.map((col: any) => ({
header: col.header || col.label,
header: col.header,
accessorKey: col.accessorKey || col.name,
width: col.width,
fitContent: col.fitContent,
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/utils/__tests__/column-identity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import {
CANONICAL_COLUMN_IDENTITY_KEY,
LEGACY_COLUMN_IDENTITY_KEYS,
TABLE_ADAPTER_COLUMN_KEY,
CANONICAL_COLUMN_LABEL_KEY,
TABLE_ADAPTER_HEADER_KEY,
columnHeader,
columnIdentity,
hasConflictingColumnIdentity,
normalizeColumnIdentity,
Expand DownExpand Up@@ -296,3 +299,71 @@ describe('conflicting-identity warning (#3104 PR3)', () => {
}
});
});

/**
* The display half of the same boundary `TABLE_ADAPTER_COLUMN_KEY` draws for
* identity (objectui#5351). `data-table` declares `header` and stopped reading
* `label`; the spec declares `label` and never had `header`. This reader is
* what each producer calls to cross between them, once, before delivery.
*/
describe('columnHeader (#5351)', () => {
it('names the two keys it bridges', () => {
expect(CANONICAL_COLUMN_LABEL_KEY).toBe('label');
expect(TABLE_ADAPTER_HEADER_KEY).toBe('header');
// The identity boundary is a separate pair, and stays separate.
expect(TABLE_ADAPTER_COLUMN_KEY).toBe('accessorKey');
});

it('reads the spec-canonical `label`', () => {
expect(columnHeader({ field: 'stage', label: 'Stage' })).toBe('Stage');
});

it('prefers an author-supplied `header` — ADAPTER-FIRST, the opposite of columnIdentity', () => {
// Deliberate asymmetry. `columnIdentity` folds several METADATA spellings
// of one metadata concept, so the canonical metadata key wins. This reader
// crosses BETWEEN vocabularies: an author who wrote the adapter's own key
// addressed the table directly, and a producer must not overwrite that.
expect(columnHeader({ header: 'Stage', label: 'ignored' })).toBe('Stage');
// ...where identity resolution goes the other way for its own pair.
expect(columnIdentity({ field: 'stage', name: 'ignored' })).toBe('stage');
});

it('returns undefined — not the empty string — when nothing is authored', () => {
// So a caller can tell "no header authored" from "the header is
// deliberately blank" and stamp nothing rather than stamping `''`.
expect(columnHeader({ field: 'stage' })).toBeUndefined();
expect(columnHeader({ header: '', label: '' })).toBeUndefined();
});

it('falls through an empty `header` to a real `label`', () => {
expect(columnHeader({ header: '', label: 'Stage' })).toBe('Stage');
});

it('ignores non-string text rather than stamping an object into a header', () => {
expect(columnHeader({ label: { en: 'Stage' } })).toBeUndefined();
expect(columnHeader({ label: 42 })).toBeUndefined();
});

it('returns undefined for a bare-string entry — a producer derives that title', () => {
// A bare `'stage'` has no display text of its own. Deriving one
// (`humanizeFieldKey`, or the object schema's label) is the producer's job
// and stays there, so this reader does not invent a second convention.
expect(columnHeader('stage')).toBeUndefined();
});

it('returns undefined for non-records', () => {
expect(columnHeader(null)).toBeUndefined();
expect(columnHeader(undefined)).toBeUndefined();
expect(columnHeader(['stage'])).toBeUndefined();
});

it('leaves the identity fold untouched — the two readers never cross', () => {
// `normalizeColumnIdentity` writes identity keys only; it must not start
// manufacturing headers now that a header reader exists next to it.
expect(normalizeColumnIdentity({ name: 'stage', label: 'Stage' })).toEqual({
field: 'stage',
name: 'stage',
label: 'Stage',
});
});
});
51 changes: 51 additions & 0 deletions packages/core/src/utils/column-identity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,3 +219,54 @@ export function normalizeColumnIdentities<T>(columns: T): T {
});
return (changed ? next : columns) as T;
}

/**
* The spec's canonical key for "what this column is CALLED on screen".
*
* `ListColumnSchema` (`@objectstack/spec/view`) spells a column's display text
* `label`, next to its `field`. It is metadata vocabulary, exactly like
* {@link CANONICAL_COLUMN_IDENTITY_KEY} — not an objectui legacy spelling, and
* not something to amputate.
*/
export const CANONICAL_COLUMN_LABEL_KEY = 'label';

/**
* NOT metadata — the display half of the same boundary
* {@link TABLE_ADAPTER_COLUMN_KEY} draws for identity.
*
* `header` is `TableColumn.header` (`packages/types/src/data-display.ts`), the
* data-table ADAPTER's own key for a column's rendered title. The adapter
* declares `header` and does not declare `label`; the spec declares `label` and
* does not declare `header`. Those are two vocabularies, and the translation
* between them belongs at the producer — one translation, one place
* (objectui#5068) — never as a tolerated `col.header || col.label` alias inside
* the adapter (objectui#5351).
*/
export const TABLE_ADAPTER_HEADER_KEY = 'header';

/**
* Read a column entry's display text on its way INTO the data-table adapter —
* the header counterpart of {@link columnIdentity} (objectui#5351).
*
* Adapter-first, which is the opposite order from {@link columnIdentity} and is
* deliberate. `columnIdentity` folds several METADATA spellings of one metadata
* concept, so the canonical metadata key wins. This reader crosses a boundary
* between two vocabularies instead: an author who wrote the adapter's own
* `header` addressed the table directly, and a producer must not overwrite that
* — the same rule `RelatedList` and `ObjectDataTable` already state for an
* author-supplied `accessorKey` (objectui#5022).
*
* Returns `undefined` — not `''` — when neither key carries text, so a caller
* can tell "no header authored" from "the header is deliberately blank" and
* stamp nothing rather than stamping an empty string.
*
* A bare-string column entry has no display text of its own; producers derive
* one from the field key (`humanizeFieldKey`) or the object schema's label, and
* that derivation is theirs, not this reader's.
*/
export function columnHeader(entry: unknown): string | undefined {
if (!isRecord(entry)) return undefined;
return (
asIdentity(entry[TABLE_ADAPTER_HEADER_KEY]) ?? asIdentity(entry[CANONICAL_COLUMN_LABEL_KEY])
);
}
Loading
Loading