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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dashboard-table-producer-column-identity-5120.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@object-ui/plugin-dashboard": minor
---

fix(plugin-dashboard): `ObjectDataTable` resolves column identity before it hands columns to the table

`normalizeColumns` converted the `string[]` shorthand and returned every object
column **raw**. `data-table` is an adapter, and its column key is `accessorKey`
(`TableColumn.accessorKey`) — a key `@object-ui/core` deliberately holds outside
the metadata identity fold, where `column-identity.ts` names it
`TABLE_ADAPTER_COLUMN_KEY`. So a column authored in the spec-canonical spelling,
`{ field: 'stage' }`, reached the adapter carrying no `accessorKey` at all: the
widget rendered a header over `row[undefined]` — every cell blank, nothing said
— and `computeLookupExpand`'s `$expand` whitelist, which resolved
`c.accessorKey || c.name`, missed the same column, so a `field`-spelled lookup
also lost its related record and showed a raw FK id.

Identity is now resolved once, here, through the shared `columnIdentity` reader
and stamped onto the adapter's key. This is the move objectui#5022 made in
`RelatedList` and objectui#5068 generalized in `ObjectGrid`: metadata vocabulary
in, adapter vocabulary out, one translation in one place.

**Affected input.** A column authored `{ field: … }` on an `object-data-table`
now renders its cells and, when the field is relational, enters `$expand`. Both
were previously empty. Columns authored `{ accessorKey: … }` are untouched, by
reference. An author-supplied `accessorKey` is never overwritten — a deliberate
divergence between the table slot and the metadata key belongs to the author —
and an entry whose identity resolves to nothing is returned untouched, so
nothing is invented for it.

The other half of objectui#5120 — retiring `data-table`'s undeclared `col.name`
alias — is **not** in this change. The card's census-first fork clause tripped:
`skills/objectui/guides/data-integration.md` and
`skills/objectui/guides/schema-expressions.md` both instruct authors to spell a
`data-table` column `{ "name": …, "label": … }`, so the limb has real authorized
usage and the deletion went back to the maintainer. This change is a
prerequisite for that deletion rather than a substitute: it is what stops
`object-data-table` from depending on the alias.
49 changes: 45 additions & 4 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 } from '@object-ui/core';
import { extractRecords, isDrillEnabled, columnIdentity } 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';
Expand DownExpand Up@@ -51,7 +51,38 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
* - Object entries are returned as-is.
* - Object entries have their field identity RESOLVED here, at the producer,
* and stamped onto the data-table adapter's own key.
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
* `TABLE_ADAPTER_COLUMN_KEY` and deliberately holds the metadata-identity fold
* away from it — so a column authored in the spec-canonical spelling
* (`{ field: 'stage' }`) reached the adapter carrying no `accessorKey` at all
* and rendered a header over `row[undefined]`: blank cells, no warning. The
* `$expand` whitelist in `computeLookupExpand` missed it for the same reason,
* so a `field`-spelled lookup column also lost its related record.
*
* Resolving it HERE is the move objectui#5022 made in `RelatedList` and
* objectui#5068 generalized in `ObjectGrid`: metadata vocabulary in, adapter
* vocabulary out, one translation in one place. The adapter stays monolingual;
* the producer owns the translation.
*
* Mirror, don't move — the same three rules `RelatedList` states:
* - an author-supplied `accessorKey` is NEVER overwritten; a deliberate
* divergence between the table slot and the metadata key belongs to the
* 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
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
* Returning the INPUT entry by reference when there is nothing to add is load
* bearing: 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.
*/
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
Expand All@@ -60,7 +91,10 @@ export function normalizeColumns(columns: (string | Record<string, any>)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
if (!col || col.accessorKey) return col as NormalizedColumn;
const key = columnIdentity(col);
if (!key) return col as NormalizedColumn;
return { ...col, accessorKey: key } as NormalizedColumn;
});
}

Expand DownExpand Up@@ -106,8 +140,15 @@ export function computeLookupExpand(

if (cols.length > 0) {
// Explicit columns whitelist: only expand the relations the user asked for.
// One reader for identity, the same one `normalizeColumns` stamps with
// (objectui#5120). This used to be `c.accessorKey || c.name` — name-first,
// and blind to the spec-canonical `field` — so a `field`-spelled lookup
// column was left out of `$expand` and its cell showed a raw FK id while
// the whitelist claimed the author had not asked for it. The adapter key
// still wins when the author supplied one, exactly as it does in
// `normalizeColumns`, so both halves resolve the same column.
const accessors = cols
.map((c: any) => (typeof c === 'string' ? c : (c.accessorKey || c.name)))
.map((c: any) => (typeof c === 'string' ? c : (c?.accessorKey || columnIdentity(c))))
.filter(Boolean);
for (const acc of accessors) {
const def = fieldsByName[acc];
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
/**
* 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 column identity at the PRODUCER (objectui#5120).
*
* `data-table` is an adapter: its column key is `accessorKey`
* (`TableColumn.accessorKey`, `@object-ui/types`), which `@object-ui/core`
* deliberately holds OUTSIDE the metadata identity fold — `column-identity.ts`
* names it `TABLE_ADAPTER_COLUMN_KEY` for exactly that reason. `normalizeColumns`
* used to hand object columns to that adapter raw, so a column authored in the
* spec-canonical spelling (`{ field: 'stage' }`) arrived with no `accessorKey`
* at all: the widget rendered a header over `row[undefined]` — blank cells, no
* warning — and `computeLookupExpand`'s `$expand` whitelist missed the same
* column, so a lookup cell showed a raw FK id.
*
* The fix is the one objectui#5022 made in `RelatedList` and objectui#5068
* generalized in `ObjectGrid`, stated there in one line: *metadata vocabulary
* in, adapter vocabulary out; one translation, one place*. This file pins the
* producer half of it for the dashboard's table widget.
*
* SCOPE. objectui#5120 also rules that `data-table`'s undeclared `col.name`
* alias (`data-table.tsx:777` / `:786`) retires. That half is NOT implemented
* here and is NOT pinned here: the card's census-first fork clause tripped —
* `skills/objectui/guides/data-integration.md` and
* `skills/objectui/guides/schema-expressions.md` both instruct authors to spell
* a `data-table` column `{ "name": …, "label": … }`, which is real authorized
* usage of the limb, so the deletion went back to the maintainer. Everything
* below is true with the alias in place and stays true after it goes: the
* producer stamps `accessorKey`, which is the one key the adapter declares.
*/
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, imported at module scope (never behind a lazy boundary
// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components`
// registers `data-table` as an import side effect, which is what the seam
// tests below resolve through.
import '@object-ui/components';

vi.mock('@object-ui/react', async () => {
const actual: any = await vi.importActual('@object-ui/react');
return {
...actual,
// Delegate to the REAL registered renderer for the node the widget emits.
// A hand-written stand-in would re-implement the adapter's accessor rule,
// and then the test would pin the stand-in rather than the seam.
SchemaRenderer: ({ schema }: any) => {
const Cmp = ComponentRegistry.get(schema.type) as any;
if (!Cmp) throw new Error(`${schema.type} not registered`);
return <Cmp schema={schema} />;
},
useDataScope: () => undefined,
SchemaRendererContext: actual.SchemaRendererContext,
};
});

import { ObjectDataTable, normalizeColumns, computeLookupExpand } from '../ObjectDataTable';

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

function renderWidget(columns: unknown[]) {
return render(
<ObjectDataTable
schema={{ type: 'object-data-table', data: ROWS, columns, pagination: false, searchable: false } as any}
/>,
);
}

/** Every rendered body cell's text, row-major. */
function bodyCells(): string[] {
return Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
}

describe('ObjectDataTable — normalizeColumns resolves identity at the producer (#5120)', () => {
it('stamps the adapter key from the spec-canonical `field`', () => {
// THE FIX. `field` is `ListColumnSchema`'s only required key and the one
// `columnIdentity` calls canonical; before this card it was invisible here.
expect(normalizeColumns([{ field: 'stage', header: 'Stage' }])).toEqual([
{ field: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('stamps the adapter key from the legacy `name` spelling too', () => {
// Not a second contract: `name` is a LEGACY_COLUMN_IDENTITY_KEY that the
// shared `columnIdentity` reader owns (objectui#3104). Reading it HERE is
// what lets the adapter stop reading it — the translation happens once, in
// the producer, instead of twice in two vocabularies.
expect(normalizeColumns([{ name: 'stage', header: 'Stage' }])).toEqual([
{ name: 'stage', header: 'Stage', accessorKey: 'stage' },
]);
});

it('never overwrites an author-supplied accessorKey, even against a divergent field', () => {
// Mirror, don't move (`RelatedList`'s rule): a deliberate divergence
// between the table slot and the metadata key belongs to the author.
const authored = { accessorKey: 'slot', field: 'stage', header: 'Stage' };
const [out] = normalizeColumns([authored]);
expect(out).toBe(authored);
expect(out.accessorKey).toBe('slot');
});

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.
const authored = { accessorKey: 'stage', header: 'Stage' };
expect(normalizeColumns([authored])[0]).toBe(authored);
});

it('returns an entry with NO resolvable identity untouched — nothing is invented', () => {
const orphan = { header: 'Mystery' };
const [out] = normalizeColumns([orphan]);
expect(out).toBe(orphan);
expect(out).not.toHaveProperty('accessorKey');
});

it('still expands the bare string shorthand into header + accessorKey', () => {
expect(normalizeColumns(['stage_name'])).toEqual([{ header: 'Stage Name', accessorKey: 'stage_name' }]);
});
});

describe('ObjectDataTable — the $expand whitelist reads the same identity (#5120)', () => {
const objectSchema = {
fields: {
account: { type: 'lookup', reference_to: 'accounts' },
stage: { type: 'text' },
},
};

it('expands a `field`-spelled lookup column', () => {
// Was `[]`: the whitelist resolved `c.accessorKey || c.name`, so a
// canonical column never entered it and its cell showed a raw FK id.
expect(computeLookupExpand({ columns: [{ field: 'account' }], objectName: 'opp' }, objectSchema)).toEqual([
'account',
]);
});

it('still expands `name`-spelled and `accessorKey`-spelled lookup columns', () => {
expect(computeLookupExpand({ columns: [{ name: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: [{ accessorKey: 'account' }] }, objectSchema)).toEqual(['account']);
expect(computeLookupExpand({ columns: ['account'] }, objectSchema)).toEqual(['account']);
});

it('leaves a non-relational column out of $expand', () => {
expect(computeLookupExpand({ columns: [{ field: 'stage' }] }, objectSchema)).toEqual([]);
});
});

describe('ObjectDataTable to data-table — the producer→adapter seam (#5120)', () => {
beforeAll(() => {
expect(ComponentRegistry.has('data-table')).toBe(true);
});

it('renders the cells of a `field`-spelled column', () => {
// The half of this card that FIXES rather than removes. Before the producer
// resolved identity, this rendered two headers over `row[undefined]`.
renderWidget([
{ field: 'stage', header: 'Stage' },
{ field: 'amount', header: 'Amount' },
]);

expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.getByText('Lost')).toBeInTheDocument();
expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('renders a declared `accessorKey` column exactly as before', () => {
renderWidget([
{ accessorKey: 'stage', header: 'Stage' },
{ accessorKey: 'amount', header: 'Amount' },
]);

expect(bodyCells()).toEqual(['Won', '100', 'Lost', '200']);
});

it('leaves an unresolvable column standing — and illegible', () => {
// LEGIBILITY, pinned as behaviour rather than left as folklore, and pinned
// as MEASURED rather than as assumed. A column whose identity resolves to
// nothing is not dropped and does not throw: its header renders over empty
// cells and its neighbour is unaffected.
//
// It is not quite silent, and the noise is the interesting part. The
// adapter keys each cell by the accessor — `key={col.accessorKey}` at
// `data-table.tsx:1829` — so an unresolved column hands React `undefined`
// and React emits its generic missing-key warning. That warning names
// `tr` and `DataTableRenderer`; it names neither the column nor the
// metadata that produced it, so it points an author at React's docs rather
// than at the key they mis-spelled. Nothing in ObjectUI's own voice is
// said at all. This is unchanged by this card — such a column carried no
// `accessorKey` before it either — and it is the same silence
// objectui#5349 is weighing for `ObjectGrid`; deliberately NOT answered
// here.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

renderWidget([
{ field: 'stage', header: 'Stage' },
{ header: 'Mystery' },
]);

expect(screen.getByText('Mystery')).toBeInTheDocument();
expect(screen.getByText('Won')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

const said = [...errorSpy.mock.calls, ...warnSpy.mock.calls].map((c) => c.join(' '));
// Not one word about the column, the key, or ObjectUI.
expect(said.some((line) => /Mystery|accessorKey|column|ObjectUI/i.test(line))).toBe(false);
// The only thing said at all is React's generic missing-key warning.
expect(said).toHaveLength(1);
expect(said[0]).toMatch(/unique "key" prop/);

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
Loading