diff --git a/.changeset/5120-retire-data-table-name-alias.md b/.changeset/5120-retire-data-table-name-alias.md new file mode 100644 index 0000000000..e9397beb38 --- /dev/null +++ b/.changeset/5120-retire-data-table-name-alias.md @@ -0,0 +1,60 @@ +--- +'@object-ui/components': minor +--- + +**Breaking for authored metadata:** a `data-table` column spelled `name` no +longer resolves its cells. Use the declared `accessorKey`. + +`data-table`'s column normalization used to read +`accessorKey: col.accessorKey || col.name` — but `TableColumn` +(`@object-ui/types`) declares only `accessorKey`, never `name`. The declared +surface admitted one spelling while the runtime admitted two, which is the +second de-facto contract AGENTS.md #0.1 forbids. The maintainer ruling of +2026-08-20 settled the direction for the whole family: retire the consumer-side +alias, translate at the producers. `label` → `header` retired first +(objectui#5351); this retires `name` → `accessorKey` and closes the family. + +**Who is affected — a column authored DIRECTLY onto a `data-table` node:** + +```json +{ "type": "data-table", + "columns": [{ "name": "email", "label": "Email" }] } // ← was tolerated +``` + +becomes + +```json +{ "type": "data-table", + "columns": [{ "header": "Email", "accessorKey": "email" }] } +``` + +**Who is NOT affected.** Columns reaching the table through `object-data-table`, +a detail view's `related[]` list, or `object-grid` are unchanged — the adapter +never sees a legacy spelling from any of them. The reason differs by producer, +and the difference matters if you are debugging one: + +- `object-data-table` and a detail view's `related[]` list **resolve** the + legacy spelling before delivery, stamping `accessorKey` from `name` (via + `columnIdentity`). A `name`-spelled column keeps working there. +- `object-grid` **refuses** it instead: since objectui#5068 an authored column + must spell the declared `field`, and one that does not is dropped at intake + and never reaches the table. Its delivered columns carry `accessorKey` + stamped from `field`. So a `name`-spelled `object-grid` column does not + render today either — that is objectui#5352's open question, unchanged by + this release. + +Only the directly-authored `data-table` node narrows here. + +**How the break presents, so you can recognise it:** the column is not dropped +and nothing is thrown — its header still renders over blank cells, and +neighbouring columns are unaffected. If a table's header row looks right but one +column's cells are empty, check that column's key spelling first. + +The two published skill guides that taught the `name` spelling +(`skills/objectui/guides/data-integration.md`, `schema-expressions.md`) migrate +in this same release, so the platform never refuses a spelling it still ships. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is a +breaking change for any author who used the tolerated spelling. It is not +`major` per this repo's fixed-group convention (objectui's own breaking changes +ship as `minor`; the group's major tracks `@objectstack`). 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 ee25891fe1..421bca4d63 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 @@ -40,11 +40,24 @@ * empty state. The correction is verified against the renderer rather * than against a reading of it. * - * The `columns` entries in these examples are deliberately untouched: their - * `{ name, label }` spelling is the separate open question on objectui#5120 - * (the undeclared `col.name` / `col.label` alias at `data-table.tsx:776-777`), - * parked with the maintainer. This file pins the BINDING only, and stays true - * whichever way that one lands — nothing below asserts a column key spelling. + * ⚠️ COLUMN SPELLING — this file is NOT spelling-independent, and an earlier + * revision of this docblock said it was. It claimed the file "pins the BINDING + * only … nothing below asserts a column key spelling". That was false and was + * filed as objectui#5479: the BEHAVIOUR assertions below are on rendered CELL + * TEXT, and a cell only has text when its column's accessor resolves — so they + * transitively pin the accessor spelling the guides use. A docblock asserting + * independence over assertions that were not independent is what made + * objectui#5120's last step invisible when the family was ruled on. + * + * Both aliases have since retired — `label` in objectui#5351, `name` in + * objectui#5120 — and the two guides' `data-table` columns migrated to the + * declared `{ header, accessorKey }` in the same commit as the `name` + * retirement, because these blocks are lifted and rendered at run time and + * would otherwise go blank. That coupling is the point, not a defect: the + * guides are executable fixtures, so the instruction corpus cannot drift from + * the runtime without this file going red. Keep it that way — if a future + * change makes the adapter's accepted key set narrower again, migrate the + * guides in the SAME commit. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -162,6 +175,31 @@ describe('skill guides — no `data-table` example is bound with `bind` (#5126, expect(offenders).toEqual([]); }); + it('no `data-table` example spells a column with the retired `name` (#5120)', () => { + // objectui#5120 retired `accessorKey: col.accessorKey || col.name` on the + // adapter. These blocks are RENDERED below, so a guide that regressed to + // `name` would already fail — but only as "expected [] to equal + // ['Ada Lovelace', …]", which names neither the key nor the card. This + // assertion is here to make the failure SAY what broke, and to hold the + // corpus even if the behaviour legs are ever narrowed. + for (const rel of GUIDE_PATHS) { + const md = readGuide(rel); + for (const node of blocksOfType(md, 'data-table')) { + const columns = (node.columns ?? []) as Record[]; + for (const col of columns) { + expect( + 'name' in col, + `${rel}: a data-table column spells the retired \`name\`; the declared key is \`accessorKey\` (objectui#5120)`, + ).toBe(false); + expect( + 'accessorKey' in col, + `${rel}: a data-table column is missing the declared \`accessorKey\` (objectui#5120)`, + ).toBe(true); + } + } + } + }); + it('no guide claims a table component calls useDataScope', () => { for (const rel of GUIDE_PATHS) { const md = readGuide(rel); diff --git a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx index c93be1a4e8..c18e833f99 100644 --- a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx +++ b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx @@ -57,9 +57,16 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, '../../../..'); const skillsRoot = path.join(repoRoot, 'skills/objectui'); +// The DECLARED column keys (`TableColumn`: `header` + `accessorKey`). These +// mirror the two published guides' `data-table` example, which is why they +// moved when the guides did: objectui#5120 retired the adapter's undeclared +// `col.name` alias, so the `{ name, label }` spelling this fixture used to +// carry now resolves no accessor and every cell below would read ''. The rows +// keep `name`/`email` as their own DATA keys — that is what `accessorKey` +// points AT, and it is unrelated to the column vocabulary. const COLUMNS = [ - { name: 'name', label: 'Name' }, - { name: 'email', label: 'Email' }, + { header: 'Name', accessorKey: 'name' }, + { header: 'Email', accessorKey: 'email' }, ]; const ROWS = [ { name: 'Ada Lovelace', email: 'ada@example.com' }, diff --git a/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx index 690cb8b0bd..fdb18db432 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx @@ -7,8 +7,9 @@ */ /** - * `data-table` reads the DECLARED `header`, not the undeclared `label` - * (objectui#5351). + * `data-table` reads the DECLARED `header` and `accessorKey`, and NOTHING else + * — not the undeclared `label` (objectui#5351), not the undeclared `name` + * (objectui#5120). * * `TableColumn` (`packages/types/src/data-display.ts`) declares `header: string` * and `accessorKey: string`. It declares neither `label` nor `name`. The @@ -26,14 +27,39 @@ * 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 + * SCOPE. `label` retired in objectui#5351; `name` retires here, closing the + * family. The hold that kept `name` alive was never about the producers — all + * three resolve `accessorKey` themselves, measured — but about the INSTRUCTION + * CORPUS: two published skill guides taught a directly authored `data-table` + * whose columns were 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 guide files. Both guides migrated to `{ header, accessorKey }` in the same + * commit as this retirement, which is the only ordering in which the platform + * never refuses a spelling it still ships. + * + * WHAT NARROWS, precisely. A `{ name, label }` column arriving through + * `ObjectDataTable`, `RelatedList` or `ObjectGrid` is UNAFFECTED — but for two + * different reasons, and conflating them is a mistake this card's own ruling + * already made once: + * + * - `ObjectDataTable` and `RelatedList` RESOLVE the legacy spelling, stamping + * `accessorKey` from `columnIdentity(col)` before delivery. + * - `ObjectGrid` does NOT fold. Since objectui#5068 it REFUSES undeclared + * spellings at intake — `resolvesToDataColumn` requires a string `field` + * (`columnSpellingDiagnostics.ts`), so a `name`-spelled entry is dropped + * before delivery and never reaches this adapter at all (before AND after + * this change; that silence is objectui#5352's territory). What it does + * deliver carries `accessorKey` stamped from `field`. + * + * The 2026-08-20 ruling's item 2 told ObjectGrid to "connect to the same + * `columnIdentity` resolution"; objectui#5478 measured that following it would + * have RE-WIDENED what #5068 narrowed. Stated here because the claim has been + * restated wrongly more than once, and a third repetition would settle it. + * + * Either way the adapter never sees a legacy spelling from a producer. What + * stops resolving is `name` on a column authored DIRECTLY onto a `data-table` + * node. That is the whole blast radius, and it is pinned below in both + * directions. * * The two aliases were DIFFERENT failure classes, which is why the cards were * filed apart: an unresolved `accessorKey` gives blank cells under a live @@ -79,24 +105,52 @@ describe('data-table columns — the declared keys render (unchanged)', () => { }); }); -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. +describe('data-table columns — the undeclared `name` alias is retired (#5120)', () => { + it('does not resolve an accessor from `name`', () => { + // The narrowing this card ships, and the receipt that replaces the HOLD pin + // that stood here while the instruction corpus still taught `name`. + // A DIFFERENT failure class from #5351's: the header is fine and the CELLS + // are what go blank. renderTable([{ header: 'Stage', name: 'stage' }]); - expect(bodyCells()).toEqual(['Won', 'Lost']); + expect(headers()).toEqual(['Stage']); + expect(bodyCells()).toEqual(['', '']); }); - it('keeps an authored `accessorKey` ahead of a divergent `name`', () => { + it('keeps an authored `accessorKey` winning over 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. + // library's own shape, and those must not be second-guessed. This passed + // before the retirement too — it is here so the two directions cannot drift. renderTable([{ header: 'Stage', accessorKey: 'stage', name: 'nonsense' }]); expect(bodyCells()).toEqual(['Won', 'Lost']); }); + + it('LEGIBILITY: an unresolvable column keeps its header and spares its neighbour', () => { + // Measured, not assumed, and deliberately pinned as the SHAPE OF THE BREAK: + // the column is not dropped and nothing throws — a live header sits over + // blank cells while the declared neighbour is untouched. This is the failure + // an author who kept the legacy spelling now gets, and it is the same + // illegible shape objectui#5349 measures against. + renderTable([ + { header: 'Stage', name: 'stage' }, + { header: 'Id', accessorKey: 'id' }, + ]); + expect(headers()).toEqual(['Stage', 'Id']); + expect(bodyCells()).toEqual(['', '1', '', '2']); + }); + + it('a PRODUCER-resolved column is unaffected — the narrowing is adapter-only', () => { + // The blast-radius bound in one assertion: whatever a producer delivers + // already carries a declared `accessorKey`, so the retirement cannot reach + // it. ObjectDataTable and RelatedList get there by RESOLVING `name` through + // `columnIdentity`; ObjectGrid gets there by REFUSING undeclared spellings + // at intake and stamping from `field` (see the header — the two routes are + // not the same mechanism). Simulating the hand-off here keeps this file + // honest about what the retirement does and does not break, without + // importing a plugin package. + renderTable([{ header: 'Stage', name: 'stage', accessorKey: 'stage' }]); + expect(headers()).toEqual(['Stage']); + expect(bodyCells()).toEqual(['Won', 'Lost']); + }); }); describe('data-table columns — the undeclared `label` alias is retired (#5351)', () => { diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index be0dc35457..7418fef471 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -832,9 +832,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { if (nonArrayDataMessage) console.warn(nonArrayDataMessage); }, [nonArrayDataMessage]); - // 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. + // The adapter reads ONLY the column keys `TableColumn` DECLARES. Both + // undeclared aliases are now retired: `label` (objectui#5351) and `name` + // (objectui#5120, this change). // // These two lines used to normalize each column as // `header: col.header || col.label` and @@ -846,28 +846,33 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // 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: + // Where the translation went — `columnIdentity` / `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. + // Metadata vocabulary in, adapter vocabulary out; one translation, one place — + // and that place is each producer, never here. A `{ name, label }` column + // arriving through any of the three still renders: its producer resolved the + // identity into `accessorKey` before the adapter ever saw it. What no longer + // resolves is `name` on a column authored DIRECTLY onto a `data-table` node, + // which is the accepted-set narrowing objectui#5120 rules and ships. // - // `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 }`: + // The instruction corpus moved in the SAME commit, which is the whole reason + // this step could be taken: `skill-guide-data-table-binding.test.tsx` lifts the + // fenced JSON out of the published guides at run time and renders it, so the + // guides are executable fixtures rather than prose. Both now teach + // `{ header, accessorKey }`: // 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. + // Retiring the runtime ahead of the instruction would have left the platform + // refusing a spelling it still shipped, and the failure it teaches into is the + // illegible one: a header over blank cells (pinned in + // `data-table-declared-column-keys.test.tsx`). const initialColumns = useMemo(() => { return rawColumns.map((col: any) => ({ ...col, - accessorKey: col.accessorKey || col.name, + accessorKey: col.accessorKey, })); }, [rawColumns]); @@ -876,10 +881,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const widths: Record = {}; // 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). + // together (objectui#5351 retired `header`'s alias, objectui#5120 `name`'s). const cols = rawColumns.map((col: any) => ({ header: col.header, - accessorKey: col.accessorKey || col.name, + accessorKey: col.accessorKey, width: col.width, fitContent: col.fitContent, })); diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md index 144ab29cfa..c108c9e43a 100644 --- a/skills/objectui/guides/data-integration.md +++ b/skills/objectui/guides/data-integration.md @@ -217,8 +217,8 @@ through is measured, with its open-question caveat, in { "name": "Grace Hopper", "email": "grace@example.com" } ], "columns": [ - { "name": "name", "label": "Name" }, - { "name": "email", "label": "Email" } + { "header": "Name", "accessorKey": "name" }, + { "header": "Email", "accessorKey": "email" } ] } ``` diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md index 0c9f63999f..367442a1b9 100644 --- a/skills/objectui/guides/schema-expressions.md +++ b/skills/objectui/guides/schema-expressions.md @@ -421,8 +421,8 @@ and is blank is the whole failure. { "name": "Grace Hopper", "email": "grace@example.com" } ], "columns": [ - { "name": "name", "label": "Name" }, - { "name": "email", "label": "Email" } + { "header": "Name", "accessorKey": "name" }, + { "header": "Email", "accessorKey": "email" } ] } ```