From 21ae8499edac8d84aa858d8525286dd64def70a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 00:53:09 +0000 Subject: [PATCH 1/2] fix(components,core,plugin-dashboard,plugin-detail): data-table reads only its declared column keys (#5120, #5351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter normalized `header: col.header || col.label` and `accessorKey: col.accessorKey || col.name`. `TableColumn` declares `header` and `accessorKey` and declares neither alias, so one key had two spellings — one the type admits, one only the runtime did (AGENTS.md #0.1). Both aliases retire. The translation moves to the producers, which is where the two vocabularies actually meet: `columnIdentity` already resolved the identity half in RelatedList (#5022), ObjectDataTable (#5120/PR5353) and ObjectGrid (#5068); a new `columnHeader` in `@object-ui/core` does the same for the display half, adapter-first so an author-supplied `header` is never overwritten. ObjectDataTable gains a fix along the way: `enrich` spreads `buildFieldMeta`'s own `label` over every column, so an authored `label` was overwritten before it ever reached the alias — a `{ field, label }` column rendered a blank header there even while the alias existed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- ...ta-table-declared-column-keys-5120-5351.md | 34 ++++ .../data-table-declared-column-keys.test.tsx | 154 ++++++++++++++++++ .../src/renderers/complex/data-table.tsx | 38 ++++- .../utils/__tests__/column-identity.test.ts | 71 ++++++++ packages/core/src/utils/column-identity.ts | 51 ++++++ .../plugin-dashboard/src/ObjectDataTable.tsx | 44 +++-- .../ObjectDataTable.columnHeader.test.tsx | 152 +++++++++++++++++ packages/plugin-detail/src/RelatedList.tsx | 14 ++ .../RelatedList.columnHeaderLabel.test.tsx | 111 +++++++++++++ 9 files changed, 651 insertions(+), 18 deletions(-) create mode 100644 .changeset/data-table-declared-column-keys-5120-5351.md create mode 100644 packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx diff --git a/.changeset/data-table-declared-column-keys-5120-5351.md b/.changeset/data-table-declared-column-keys-5120-5351.md new file mode 100644 index 0000000000..26a59db737 --- /dev/null +++ b/.changeset/data-table-declared-column-keys-5120-5351.md @@ -0,0 +1,34 @@ +--- +'@object-ui/core': minor +'@object-ui/components': minor +'@object-ui/plugin-dashboard': minor +'@object-ui/plugin-detail': minor +--- + +`data-table` reads only the two column keys it declares; the producers translate. + +`TableColumn` declares `header: string` and `accessorKey: string`. The renderer's +column normalization nonetheless read `header: col.header || col.label` and +`accessorKey: col.accessorKey || col.name` — two undeclared aliases for two +declared keys, so the same key had one spelling the type admits and one only the +runtime did. Both aliases are now gone (objectui#5120, objectui#5351), and the +translation that used to happen inside the adapter happens once at each producer +instead: metadata vocabulary in, adapter vocabulary out, one place. + +**This narrows what `data-table` accepts, so read this if you author `data-table` +nodes by hand.** A column spelled `{ name, label }` on a directly authored +`data-table` no longer resolves: `name` gives blank cells under a live header, +`label` gives a headerless column over live cells. Neither is dropped and neither +throws. Spell such a column `{ accessorKey, header }` — the two keys `TableColumn` +has always declared. Columns reaching `data-table` through `object-data-table`, +`object-grid` or a related list are unaffected; those producers resolve both keys +for you, and every spelling they accepted before they still accept. + +`@object-ui/core` gains `columnHeader()` alongside `columnIdentity()` — the reader +producers use to cross that boundary, 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". 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 new file mode 100644 index 0000000000..67389084eb --- /dev/null +++ b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx @@ -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 two column keys `TableColumn` DECLARES, and no others + * (objectui#5120 for `accessorKey`, objectui#5351 for `header`). + * + * `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. + * + * The two aliases were DIFFERENT failure classes and are pinned separately: + * 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( + , + ); +} + +/** 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 undeclared `name` alias is retired (#5120)', () => { + it('does not resolve an accessor from `name`', () => { + // THE CHANGE. Before the retirement this rendered both cells, because the + // adapter fell back to `col.name`. The declared key is `accessorKey`, and a + // producer resolves it (`columnIdentity`) before delivery. + renderTable([{ header: 'Stage', name: 'stage' }]); + expect(bodyCells()).toEqual(['', '']); + }); + + it('keeps an authored `accessorKey` winning over a divergent `name`', () => { + // `name` is not consulted at all, so it cannot win and cannot conflict. + renderTable([{ header: 'Stage', accessorKey: 'stage', name: 'nonsense' }]); + expect(bodyCells()).toEqual(['Won', 'Lost']); + }); + + it('LEGIBILITY: an unresolvable accessor keeps its header and its neighbour', () => { + // Measured, not assumed, and deliberately unchanged by this card: the + // column is neither dropped nor refused. It renders a live header over + // blank cells, and the column beside it is unaffected. Whether that silence + // earns a dev-time diagnostic is objectui#5349's question, not this one's. + renderTable([ + { header: 'Stage', name: 'stage' }, + { header: 'Id', accessorKey: 'id' }, + ]); + expect(headers()).toEqual(['Stage', 'Id']); + expect(bodyCells()).toEqual(['', '1', '', '2']); + }); +}); + +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 two keys', () => { + it('sizes from the declared keys, not the retired aliases', () => { + // The width pass is a SECOND read of the same columns, five lines below the + // first. If the two ever spell the accessor differently the table measures + // one set of columns and renders another, so they are pinned to move + // together. A `name`-spelled column samples `row[undefined]`, contributes no + // content width and falls to the 80px floor; its declared twin over the + // same long values does not. + const DataTable = ComponentRegistry.get('data-table') as any; + const LONG = 'a-really-quite-long-cell-value-here'; + render( + , + ); + 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); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index ddee5e0737..222b465bf7 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -769,21 +769,43 @@ 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 is MONOLINGUAL: it reads the two keys `TableColumn` declares, + // `header` and `accessorKey`, and no others (objectui#5120, objectui#5351). + // + // 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 `label` nor + // `name`. 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 shape objectui#3951 already settled: unify at the PRODUCER, keep no + // consumer-side tolerance alias. + // + // Where the translation went instead — `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. + // + // The shallow copy stays even though nothing is added to it any more: the + // list seeded into `columns` state below must not be the caller's own column + // objects, and `columnsAreEquivalent` compares by value, so a fresh array per + // render costs nothing (objectui#4618). const initialColumns = useMemo(() => { - return rawColumns.map((col: any) => ({ - ...col, - header: col.header || col.label, - accessorKey: col.accessorKey || col.name - })); + return rawColumns.map((col: any) => ({ ...col })); }, [rawColumns]); // Auto-size columns: estimate width from header and data content for columns without explicit widths const autoSizedWidths = useMemo(() => { const widths: Record = {}; + // Same two declared keys as `initialColumns` above — the auto-width pass + // must measure the SAME columns the table renders, so the two reads stay + // spelled identically (objectui#5120, objectui#5351). const cols = rawColumns.map((col: any) => ({ - header: col.header || col.label, - accessorKey: col.accessorKey || col.name, + header: col.header, + accessorKey: col.accessorKey, width: col.width, fitContent: col.fitContent, })); diff --git a/packages/core/src/utils/__tests__/column-identity.test.ts b/packages/core/src/utils/__tests__/column-identity.test.ts index cf077125d1..79b6c32212 100644 --- a/packages/core/src/utils/__tests__/column-identity.test.ts +++ b/packages/core/src/utils/__tests__/column-identity.test.ts @@ -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, @@ -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', + }); + }); +}); diff --git a/packages/core/src/utils/column-identity.ts b/packages/core/src/utils/column-identity.ts index 0187c417ef..54c9c6a595 100644 --- a/packages/core/src/utils/column-identity.ts +++ b/packages/core/src/utils/column-identity.ts @@ -219,3 +219,54 @@ export function normalizeColumnIdentities(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]) + ); +} diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index eacd29f707..b86a880218 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -8,7 +8,7 @@ import React, { useState, useEffect, useContext, useMemo, useCallback } from 'react'; import { useDataScope, SchemaRendererContext, SchemaRenderer, useFilterScope } from '@object-ui/react'; -import { extractRecords, isDrillEnabled, columnIdentity } from '@object-ui/core'; +import { extractRecords, isDrillEnabled, columnIdentity, columnHeader } from '@object-ui/core'; import type { DrillDownConfig } from '@object-ui/types'; import { Skeleton, RefreshIndicator, cn } from '@object-ui/components'; import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n'; @@ -51,8 +51,10 @@ interface NormalizedColumn { * * - `string[]` entries are converted to `{ header, accessorKey }` objects, * handling both snake_case and camelCase for header generation. - * - Object entries have their field identity RESOLVED here, at the producer, - * and stamped onto the data-table adapter's own key. + * - Object entries have their field identity AND their display text RESOLVED + * here, at the producer, and stamped onto the data-table adapter's own keys + * (`accessorKey` / `header`). The adapter reads only those two, and no longer + * falls back to `name` / `label` (objectui#5120, objectui#5351). * * Object entries used to be returned raw (objectui#5120). `accessorKey` is the * table LIBRARY's column key — `column-identity.ts` names it @@ -74,9 +76,9 @@ interface NormalizedColumn { * author; * - the authored spelling is left in place, so a host reading `field` / `name` * back off these columns keeps working; - * - an entry with no resolvable identity is returned UNTOUCHED — nothing is - * invented for it. It behaves exactly as it does today: a header (from - * `header` / `label`) over empty cells, silently. Whether that silence + * - an entry with neither a resolvable identity nor any display text is + * returned UNTOUCHED — nothing is invented for it. It behaves exactly as it + * does today: a header over empty cells, silently. Whether that silence * deserves a dev-time diagnostic is objectui#5349's question, and is * deliberately NOT answered here. * @@ -91,10 +93,32 @@ export function normalizeColumns(columns: (string | Record)[]): Nor // widget family spell a header the same way (objectui#4618). return { header: humanizeFieldKey(col), accessorKey: col }; } - if (!col || col.accessorKey) return col as NormalizedColumn; - const key = columnIdentity(col); - if (!key) return col as NormalizedColumn; - return { ...col, accessorKey: key } as NormalizedColumn; + if (!col) return col as NormalizedColumn; + const patch: Record = {}; + // Identity: `accessorKey` is the adapter's key, so an author who supplied + // it addressed the table directly and is never second-guessed. + if (!col.accessorKey) { + const key = columnIdentity(col); + if (key) patch.accessorKey = key; + } + // Display text: the same boundary, seen from the label side + // (objectui#5351). The spec spells it `label`, the adapter spells it + // `header`, and the adapter no longer reads `label` — so the translation + // happens HERE, before delivery, or the column arrives headerless. + // + // This is a FIX as well as a move: `enrich` below spreads `buildFieldMeta`'s + // result over the column, and that result carries its own `label` (built + // from `col.header`), so an authored `label` was overwritten before it ever + // reached the adapter's alias. A `{ field, label }` column rendered a BLANK + // header here even while the alias still existed — measured, not assumed. + if (!col.header) { + const text = columnHeader(col); + if (text) patch.header = text; + } + // Nothing to add: return the INPUT entry by reference, so data-table's + // column-state re-seed stays quiet on the common path (objectui#4618). + if (Object.keys(patch).length === 0) return col as NormalizedColumn; + return { ...col, ...patch } as NormalizedColumn; }); } diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx new file mode 100644 index 0000000000..ce6d28ee99 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx @@ -0,0 +1,152 @@ +/** + * 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. + */ + +/** + * `ObjectDataTable` resolves a column's DISPLAY TEXT at the producer, before + * data-table (objectui#5351) — the header counterpart of the identity stamp + * objectui#5120 put in the same function. + * + * The spec spells a column's text `ListColumnSchema.label`; the data-table + * adapter spells it `TableColumn.header` and, since the maintainer ruling of + * 2026-08-20, reads only that. So the translation happens HERE, once, or the + * column arrives headerless. + * + * This is a FIX and not only a move, which is the part worth pinning: a + * `label`-spelled column rendered a BLANK header on this widget even while the + * adapter's alias still existed. `enrich` spreads `buildFieldMeta`'s result over + * every column, and that result carries its own `label` (built from + * `col.header`), so an authored `label` was overwritten with `undefined` before + * it ever reached the alias. Measured on `origin/main`, not assumed: + * + * { label: 'Stage', accessorKey: 'stage' } -> headers=[""] cells=["Won","Lost"] + * { field: 'stage', label: 'Stage' } -> headers=[""] cells=["Won","Lost"] + * + * Both now render "Stage". + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; + +// The REAL renderers: `@object-ui/components` registers `data-table` as an +// import side effect, and these pins resolve through that seam rather than a +// stand-in that would re-implement the adapter's header rule. +import '@object-ui/components'; + +vi.mock('@object-ui/react', async () => { + const actual: any = await vi.importActual('@object-ui/react'); + return { + ...actual, + SchemaRenderer: ({ schema }: any) => { + const Cmp = ComponentRegistry.get(schema.type) as any; + if (!Cmp) throw new Error(`${schema.type} not registered`); + return ; + }, + useDataScope: () => undefined, + SchemaRendererContext: actual.SchemaRendererContext, + }; +}); + +import { ObjectDataTable, normalizeColumns } from '../ObjectDataTable'; + +const ROWS = [ + { id: '1', stage: 'Won' }, + { id: '2', stage: 'Lost' }, +]; + +function renderWidget(columns: unknown[]) { + return render( + , + ); +} + +const headers = () => + Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); +const bodyCells = () => + Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim()); + +describe('ObjectDataTable — normalizeColumns stamps the adapter header (#5351)', () => { + it('maps the spec-canonical `label` onto the adapter key `header`', () => { + expect(normalizeColumns([{ field: 'stage', label: 'Stage' }])).toEqual([ + { field: 'stage', label: 'Stage', accessorKey: 'stage', header: 'Stage' }, + ]); + }); + + it('never overwrites an author-supplied `header`, even against a divergent `label`', () => { + // The same rule `accessorKey` gets: an author who wrote the adapter's own + // key addressed the table directly and is not second-guessed. + expect(normalizeColumns([{ field: 'stage', label: 'ignored', header: 'Stage' }])).toEqual([ + { field: 'stage', label: 'ignored', header: 'Stage', accessorKey: 'stage' }, + ]); + }); + + it('stamps a header even when the identity needs no stamping', () => { + // The two halves are independent: an `accessorKey`-spelled column with a + // `label` needs the header translation and nothing else. + expect(normalizeColumns([{ accessorKey: 'stage', label: 'Stage' }])).toEqual([ + { accessorKey: 'stage', label: 'Stage', header: 'Stage' }, + ]); + }); + + it('invents no header for a column that carries no display text', () => { + const bare = { field: 'stage' }; + expect(normalizeColumns([bare])).toEqual([{ field: 'stage', accessorKey: 'stage' }]); + expect(normalizeColumns([bare])[0]).not.toHaveProperty('header'); + }); + + it('returns an already-canonical entry BY REFERENCE', () => { + // Load bearing, not a micro-optimisation: data-table re-seeds its column + // state whenever the list is a new object (objectui#4618) and this widget + // rebuilds its node on every render. The added header branch must not cost + // that stability. + const authored = { accessorKey: 'stage', header: 'Stage' }; + expect(normalizeColumns([authored])[0]).toBe(authored); + }); + + it('returns a wholly unresolvable entry BY REFERENCE too', () => { + const orphan = { width: 120 }; + expect(normalizeColumns([orphan])[0]).toBe(orphan); + }); + + it('ignores a non-string `label` rather than stamping an object into the header', () => { + // `asIdentity` in `column-identity.ts` accepts only a non-empty string, so + // an i18n record or a stray number never becomes a header. + const weird = { accessorKey: 'stage', label: { en: 'Stage' } }; + expect(normalizeColumns([weird as any])[0]).not.toHaveProperty('header'); + }); +}); + +describe('ObjectDataTable to data-table — the header reaches the screen (#5351)', () => { + beforeAll(() => { + expect(ComponentRegistry.has('data-table')).toBe(true); + }); + + it('renders the header of a `label`-spelled column', () => { + renderWidget([{ label: 'Stage', accessorKey: 'stage' }]); + expect(headers()).toEqual(['Stage']); + expect(bodyCells()).toEqual(['Won', 'Lost']); + expect(screen.getByText('Stage')).toBeInTheDocument(); + }); + + it('renders header AND cells for a fully spec-spelled column', () => { + // Both halves of the ruling at once: `field` -> `accessorKey` (#5120) and + // `label` -> `header` (#5351), resolved in one place before delivery. + renderWidget([{ field: 'stage', label: 'Stage' }]); + expect(headers()).toEqual(['Stage']); + expect(bodyCells()).toEqual(['Won', 'Lost']); + }); + + it('renders a declared `header` / `accessorKey` column exactly as before', () => { + renderWidget([{ header: 'Stage', accessorKey: 'stage' }]); + expect(headers()).toEqual(['Stage']); + expect(bodyCells()).toEqual(['Won', 'Lost']); + }); +}); diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 0443f42ca3..775c006c60 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -43,6 +43,7 @@ import type { ViewFilterRule } from '@objectstack/spec/ui'; import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns } from '@object-ui/fields'; import { columnIdentity, + columnHeader, compareSortValues, getRecordDisplayName, getSortValue, @@ -971,6 +972,19 @@ export const RelatedList: React.FC = ({ if (!c || !key) return c; const patch: Record = {}; if (!c.accessorKey) patch.accessorKey = key; + // The display half of the SAME boundary (objectui#5351). The spec + // spells a column's text `ListColumnSchema.label`; the adapter spells + // it `TableColumn.header` and, since objectui#5120, reads only that. + // The alias it used to carry (`header: col.header || col.label`) is + // gone, so this producer translates instead — otherwise every + // `record_related_list` block whose columns are authored the spec way + // arrives headerless. An author-supplied `header` is never overwritten, + // for the same reason `accessorKey` isn't: it addresses the table + // directly. + if (!c.header) { + const text = columnHeader(c); + if (text) patch.header = text; + } // Attach a cell renderer when it lacks one and we can resolve the // field def — preserves any author-supplied cell/render. if (!c.cell && !c.render) { diff --git a/packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx new file mode 100644 index 0000000000..366a2290a7 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx @@ -0,0 +1,111 @@ +/** + * 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. + */ + +/** + * `RelatedList` translates a column's DISPLAY TEXT into the adapter's + * vocabulary before delivery (objectui#5351) — the header counterpart of the + * identity stamp objectui#5022 put in the same function. + * + * objectui#5022 moved IDENTITY resolution here and deliberately left the header + * alone, because `data-table` still read `header: col.header || col.label`. The + * 2026-08-20 ruling retired that alias, so the other half of the translation has + * to move here with it. Without it, every `record_related_list` block whose + * columns are authored the spec way (`{ field, label }` — the shape + * `ListColumnSchema` declares, and the shape `record-related-list.tsx` passes + * straight through as `columns`) would arrive HEADERLESS: live cells under a + * blank title. That is a different failure from #5120's blank-cells-under-a-live + * -header, which is why the two were filed as separate cards. + * + * Pinned through the REAL `data-table`, because a schema-level assertion cannot + * see it: the column object looks complete at every RelatedList read site — it + * always did, which is what made the identity half silent too. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { RelatedList } from '../RelatedList'; + +const fields = { + status: { + type: 'select', + label: 'SchemaLabel', + options: [ + { value: 'planned', label: 'Planned' }, + { value: 'running', label: 'Running' }, + ], + }, +}; + +const rows = [ + { id: 't1', status: 'planned' }, + { id: 't2', status: 'running' }, +]; + +const makeDS = () => ({ + find: vi.fn(async () => rows), + getObjectSchema: vi.fn(async () => ({ name: 'task', fields })), +}); + +function renderList(columns: any[]) { + return render( + , + ); +} + +/** Every rendered header cell's text, in order. */ +const headers = () => + Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); + +describe('RelatedList — the spec `label` reaches the adapter as `header` (#5351)', () => { + it('renders the header of a fully spec-spelled column', async () => { + renderList([{ field: 'status', label: 'Status' }]); + await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument()); + expect(headers()).toEqual(['Status']); + }); + + it('renders the header of a legacy `name` + `label` column', async () => { + // `name` is objectui-side legacy that `columnIdentity` still folds; the + // header translation is independent of which identity spelling was used. + renderList([{ name: 'status', label: 'Status' }]); + await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument()); + expect(headers()).toEqual(['Status']); + }); + + it('renders the header of an adapter-spelled column unchanged', async () => { + renderList([{ accessorKey: 'status', header: 'Status' }]); + await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument()); + expect(headers()).toEqual(['Status']); + }); + + it('never overwrites an author-supplied `header` with a divergent `label`', async () => { + renderList([{ field: 'status', label: 'ignored', header: 'Status' }]); + await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument()); + expect(headers()).toEqual(['Status']); + expect(screen.queryByText('ignored')).not.toBeInTheDocument(); + }); + + it('leaves a column with no display text headerless — nothing is invented', async () => { + // Deliberately NOT derived from the field def here: the object-schema + // fallback belongs to the bare-string branch, which is where an author who + // supplied no column object at all asked for a derived title. An object + // column that names no text keeps rendering as it did. + renderList([{ field: 'status' }]); + await waitFor(() => expect(document.querySelectorAll('thead th').length).toBeGreaterThan(0)); + expect(headers()).toEqual(['']); + expect(screen.queryByText('SchemaLabel')).not.toBeInTheDocument(); + }); +}); From 237088264b2c0bf7866b7b100febd8e078bb606d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 01:18:46 +0000 Subject: [PATCH 2/2] fix(components,plugin-detail,plugin-dashboard): hold the `name` alias; the `label` alias retires (#5351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `col.name` retirement is measured and ready but cannot land yet: `skill-guide-data-table-binding.test.tsx` renders the JSON blocks lifted from skills/objectui/guides/{data-integration,schema-expressions}.md, and both teach a directly authored `data-table` with `{ name, label }` columns. The runtime must not refuse a spelling the shipped instruction set still teaches, and `skills/**` is another seat's surface. Measured split: with `col.label` retired and `col.name` held, that gate is 15/15 green — the label half is invisible to it. Only the name half breaks it, on 2 tests. #5351 therefore lands in full and #5120 keeps its remaining step. The held alias is pinned as still-read, so the day the guides move that pin goes red and names itself as the thing to delete. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- ...ta-table-declared-column-keys-5120-5351.md | 34 -------- .../data-table-header-declared-key-5351.md | 36 +++++++++ .../data-table-declared-column-keys.test.tsx | 80 +++++++++---------- .../src/renderers/complex/data-table.tsx | 45 +++++++---- 4 files changed, 104 insertions(+), 91 deletions(-) delete mode 100644 .changeset/data-table-declared-column-keys-5120-5351.md create mode 100644 .changeset/data-table-header-declared-key-5351.md diff --git a/.changeset/data-table-declared-column-keys-5120-5351.md b/.changeset/data-table-declared-column-keys-5120-5351.md deleted file mode 100644 index 26a59db737..0000000000 --- a/.changeset/data-table-declared-column-keys-5120-5351.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@object-ui/core': minor -'@object-ui/components': minor -'@object-ui/plugin-dashboard': minor -'@object-ui/plugin-detail': minor ---- - -`data-table` reads only the two column keys it declares; the producers translate. - -`TableColumn` declares `header: string` and `accessorKey: string`. The renderer's -column normalization nonetheless read `header: col.header || col.label` and -`accessorKey: col.accessorKey || col.name` — two undeclared aliases for two -declared keys, so the same key had one spelling the type admits and one only the -runtime did. Both aliases are now gone (objectui#5120, objectui#5351), and the -translation that used to happen inside the adapter happens once at each producer -instead: metadata vocabulary in, adapter vocabulary out, one place. - -**This narrows what `data-table` accepts, so read this if you author `data-table` -nodes by hand.** A column spelled `{ name, label }` on a directly authored -`data-table` no longer resolves: `name` gives blank cells under a live header, -`label` gives a headerless column over live cells. Neither is dropped and neither -throws. Spell such a column `{ accessorKey, header }` — the two keys `TableColumn` -has always declared. Columns reaching `data-table` through `object-data-table`, -`object-grid` or a related list are unaffected; those producers resolve both keys -for you, and every spelling they accepted before they still accept. - -`@object-ui/core` gains `columnHeader()` alongside `columnIdentity()` — the reader -producers use to cross that boundary, 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". diff --git a/.changeset/data-table-header-declared-key-5351.md b/.changeset/data-table-header-declared-key-5351.md new file mode 100644 index 0000000000..c0b154de2d --- /dev/null +++ b/.changeset/data-table-header-declared-key-5351.md @@ -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. 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 67389084eb..d6c11f5692 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,8 @@ */ /** - * `data-table` reads the two column keys `TableColumn` DECLARES, and no others - * (objectui#5120 for `accessorKey`, objectui#5351 for `header`). + * `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 @@ -26,11 +26,20 @@ * purpose. Metadata vocabulary in, adapter vocabulary out; one translation, one * place — and that place is each producer, never here. * - * The two aliases were DIFFERENT failure classes and are pinned separately: - * 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. + * 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'; @@ -69,33 +78,24 @@ describe('data-table columns — the declared keys render (unchanged)', () => { }); }); -describe('data-table columns — the undeclared `name` alias is retired (#5120)', () => { - it('does not resolve an accessor from `name`', () => { - // THE CHANGE. Before the retirement this rendered both cells, because the - // adapter fell back to `col.name`. The declared key is `accessorKey`, and a - // producer resolves it (`columnIdentity`) before delivery. +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(['', '']); + expect(bodyCells()).toEqual(['Won', 'Lost']); }); - it('keeps an authored `accessorKey` winning over a divergent `name`', () => { - // `name` is not consulted at all, so it cannot win and cannot conflict. + 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']); }); - - it('LEGIBILITY: an unresolvable accessor keeps its header and its neighbour', () => { - // Measured, not assumed, and deliberately unchanged by this card: the - // column is neither dropped nor refused. It renders a live header over - // blank cells, and the column beside it is unaffected. Whether that silence - // earns a dev-time diagnostic is objectui#5349's question, not this one's. - renderTable([ - { header: 'Stage', name: 'stage' }, - { header: 'Id', accessorKey: 'id' }, - ]); - expect(headers()).toEqual(['Stage', 'Id']); - expect(bodyCells()).toEqual(['', '1', '', '2']); - }); }); describe('data-table columns — the undeclared `label` alias is retired (#5351)', () => { @@ -122,26 +122,26 @@ describe('data-table columns — the undeclared `label` alias is retired (#5351) }); }); -describe('data-table columns — the auto-width pass reads the same two keys', () => { - it('sizes from the declared keys, not the retired aliases', () => { - // The width pass is a SECOND read of the same columns, five lines below the - // first. If the two ever spell the accessor differently the table measures - // one set of columns and renders another, so they are pinned to move - // together. A `name`-spelled column samples `row[undefined]`, contributes no - // content width and falls to the 80px floor; its declared twin over the - // same long values does not. +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-cell-value-here'; + const LONG = 'A Really Quite Long Column Header'; render( , diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 222b465bf7..8deb45bf1f 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -769,43 +769,54 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // every downstream memo on each render (objectui#4618). const data = Array.isArray(rawData) ? rawData : EMPTY_ROWS; - // The adapter is MONOLINGUAL: it reads the two keys `TableColumn` declares, - // `header` and `accessorKey`, and no others (objectui#5120, objectui#5351). + // 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 `label` nor - // `name`. 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 shape objectui#3951 already settled: unify at the PRODUCER, keep no - // consumer-side tolerance alias. + // `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. // - // Where the translation went instead — `columnIdentity` / `columnHeader` in + // `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. // - // The shallow copy stays even though nothing is added to it any more: the - // list seeded into `columns` state below must not be the caller's own column - // objects, and `columnsAreEquivalent` compares by value, so a fresh array per - // render costs nothing (objectui#4618). + // `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 })); + return rawColumns.map((col: any) => ({ + ...col, + 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 = {}; - // Same two declared keys as `initialColumns` above — the auto-width pass - // must measure the SAME columns the table renders, so the two reads stay - // spelled identically (objectui#5120, objectui#5351). + // 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, - accessorKey: col.accessorKey, + accessorKey: col.accessorKey || col.name, width: col.width, fitContent: col.fitContent, }));