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 new file mode 100644 index 0000000000..d6c11f5692 --- /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 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( + , + ); +} + +/** 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( + , + ); + 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..8deb45bf1f 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -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 = {}; + // 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, 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(); + }); +});