From aebe6166b2bdeaea962abf0950eeb7d6f6703cf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 13:42:00 +0000 Subject: [PATCH 1/2] fix(plugin-view): forward the canonical `table.columns` on the non-grid paths (#5269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectViewSchema.table` inherits from `ObjectGridSchema`, where `columns` is canonical and `fields` is `@deprecated Use columns instead`. Only one of the file's three field-list read points consulted `table.columns` — the grid one. `generateViewSchema`'s shared `baseProps` and the delegated `renderListView` schema read `table.fields` alone, so `table: { columns: [...] }` produced an empty field list off the grid path from a schema that compiled and read correctly. Both sites now read the canonical key first, keeping the deprecated one as a working alias (the shape objectui#5102 settled for its four pairs). Forwarding, not translation; precedence unchanged. `schema.table?.columns` joins the `generateViewSchema` dependency list alongside the read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .../objectview-table-columns-non-grid-5269.md | 27 ++ packages/plugin-view/src/ObjectView.tsx | 43 ++- ...ObjectView.tableColumnsForwarding.test.tsx | 257 ++++++++++++++++++ 3 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 .changeset/objectview-table-columns-non-grid-5269.md create mode 100644 packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx diff --git a/.changeset/objectview-table-columns-non-grid-5269.md b/.changeset/objectview-table-columns-non-grid-5269.md new file mode 100644 index 000000000..dd799fe21 --- /dev/null +++ b/.changeset/objectview-table-columns-non-grid-5269.md @@ -0,0 +1,27 @@ +--- +'@object-ui/plugin-view': patch +--- + +`ObjectView` now forwards the canonical `table.columns` on the non-grid paths, not only on the grid one. + +`ObjectViewSchema.table` inherits from `ObjectGridSchema`, where `columns` is the +canonical spelling and `fields` carries `@deprecated Use columns instead`. Only +one of the file's three field-list read points consulted `table.columns` — the +grid one. `generateViewSchema`'s shared `baseProps` and the delegated +`renderListView` schema both read `table.fields` alone, so an author who wrote +`table: { columns: [...] }` on a non-grid view got an empty field list from a +schema that compiled and read correctly. Same silent-success shape as +objectui#5102, different mechanism: not a whitelist that knows only legacy +spellings, but one that disagreed with itself between two rendering paths. + +Both sites now read the canonical key first and keep the deprecated one as a +working alias, exactly as objectui#5102 settled it for its four pairs. Nothing +is translated or reshaped on the way through, and precedence is unchanged: a +named view's `columns`, then the active view's, then the `table` segment. + +Where this is observable, measured rather than assumed: `object-kanban` (the +card fields) and `object-tree` (its flat columns) consume the shared +`baseProps` field list, and the delegated `list-view` consumes `columns`. +`object-gallery`, `object-calendar`, `object-timeline`, `object-gantt` and +`object-map` read no field list off their schema at all, so the forwarded value +is inert there — before this change and after it. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 870119efd..4e8c44ae4 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -839,7 +839,33 @@ export const ObjectView: React.FC = ({ const generateViewSchema = useCallback((viewType: string): any => { const baseProps: Record = { objectName: schema.objectName, - fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields, + // objectui#5269 — the `table` segment reads the CANONICAL key first. + // + // `ObjectGridSchema.columns` is the canonical spelling and `fields` is + // its `@deprecated` alias ("@deprecated Use columns instead"), and + // `ObjectViewSchema.table` is `Partial< Omit< ObjectGridSchema, … > >`, + // so `table: { columns: [...] }` is the shape the type recommends. It + // reached the grid path (which forwards `table.columns` into its own + // `columns` slot) and stopped here: this line read the deprecated half + // alone, so an author who wrote the canonical key on a non-grid view got + // an empty field list from a compile-clean, semantically correct schema. + // Same user-visible shape as objectui#5102, different mechanism — not a + // whitelist that knows only legacy spellings, but one that disagreed + // with itself between two rendering paths. + // + // Forwarding, not translation, exactly as objectui#5102 settled it: the + // value is handed on unchanged and the two segments ahead of `table` + // keep their precedence. Both spellings stay working; only the ORDER + // between them is stated, canonical first. + // + // Reach, measured rather than assumed: of the surfaces this `baseProps` + // feeds, `object-kanban` consumes it (via `cardFields`, below) and + // `object-tree` consumes it (as its own `fields`). `object-gallery` / + // `object-calendar` / `object-timeline` / `object-gantt` / `object-map` + // read NO field list off their schema at all, so the value is inert + // there — before this change and after it. + fields: currentNamedViewConfig?.columns || activeView?.columns + || schema.table?.columns || schema.table?.fields, className: 'h-full w-full', showSearch: activeView?.showSearch ?? schema.showSearch ?? false, showSort: activeView?.showSort ?? schema.showSort ?? false, @@ -1018,7 +1044,10 @@ export const ObjectView: React.FC = ({ default: return null; } - }, [schema.objectName, schema.table?.fields, currentNamedViewConfig, activeView]); + // `schema.table?.columns` joins the list with the read added for + // objectui#5269: a memo that reads a key but does not depend on it keeps + // serving the field list the author has already replaced. + }, [schema.objectName, schema.table?.columns, schema.table?.fields, currentNamedViewConfig, activeView]); // Build grid schema (default content renderer) // @@ -1281,7 +1310,15 @@ export const ObjectView: React.FC = ({ // Spec-canonical key (#2890) — the view configs this reads from are // already `columns`-keyed, so emitting `fields` here was a pure // canonical→legacy downgrade. - columns: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields, + // + // objectui#5269: the `table` segment reads the canonical key first + // here too. This slot is `list-view`'s `columns`, declared + // `string[] | ListColumn[]` — the same union `table.columns` carries + // — so the canonical value arrives in a slot already shaped to hold + // it, and `ListView` reads it (`schema.columns`, its whole column + // set). The deprecated `table.fields` stays a working alias. + columns: currentNamedViewConfig?.columns || activeView?.columns + || schema.table?.columns || schema.table?.fields, filter: mergedFilters, sort: mergedSort, // Propagate appearance/view-config properties for live preview diff --git a/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx new file mode 100644 index 000000000..05c74524a --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx @@ -0,0 +1,257 @@ +/** + * 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. + */ + +/** + * objectui#5269 — `table.columns` reaches the NON-grid paths too. + * + * `ObjectViewSchema.table` is `Partial< Omit< ObjectGridSchema, 'type' | + * 'objectName' > >`, where `columns` is the canonical spelling and `fields` + * carries "@deprecated Use columns instead". `ObjectView` does not spread + * `table`; it forwards a hand-written whitelist, and that whitelist had ONE + * read of `table.columns` in the whole file — the grid one. Both other field- + * list read points took the deprecated key alone: + * + * generateViewSchema `baseProps` `table.fields` only + * delegated `renderListView` schema `table.fields` only + * + * So `table: { columns: [...] }` — the shape the type recommends — produced an + * empty field list off the grid path, from a schema that compiled and read + * correctly. Same silent-success shape as objectui#5102; different mechanism: + * not a whitelist that knows only legacy spellings, but one that disagreed + * with itself between two rendering paths. + * + * ## What this file pins, and on which surface + * + * Both changed sites are pinned where a CONSUMER can be observed, not merely + * where the value is emitted: + * + * `baseProps` -> `object-kanban` consumed as `cardFields` + * (`ObjectKanban` reads `schema.cardFields`) + * `baseProps` -> `object-tree` consumed as `fields` + * (`ObjectTree.getTreeConfig` reads `schema.fields`) + * delegated -> `list-view` consumed as `columns` (`ListView`'s column set) + * + * ## The surfaces that get NO test here, deliberately + * + * `baseProps` also feeds `object-gallery`, `object-calendar`, `object-timeline`, + * `object-gantt` and `object-map`. Measured on this tree, none of those five + * renderers reads a field list off its schema at all — every `.fields` in them + * is `objectDef.fields` (object METADATA) or gallery's `schema.grouping.fields`. + * So the value this fix forwards is inert on those five, before the fix and + * after it. Asserting `fields` on their generated schema would pin the emit + * and prove nothing about the surface, so it is stated here instead of tested. + * (The card objectui#5269 names kanban / gallery / calendar as the affected + * surfaces; the measurable set is kanban / tree / delegated.) + * + * ## Precedence + * + * Unchanged, and pinned below: the two segments ahead of `table` + * (`listViews` entry, then `activeView`) still outrank it. Within the `table` + * segment the canonical key wins and the deprecated one keeps working — the + * same rule objectui#5102 applied to its four pairs. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +/** Every schema the view hands to SchemaRenderer, in order. */ +const rendered: any[] = []; + +vi.mock('@object-ui/react', async () => { + const React = await import('react'); + return { + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); + +/** The grid the view delegates to, replaced by a probe that records its schema. */ +const gridSchemas: any[] = []; +vi.mock('@object-ui/plugin-grid', () => ({ + ObjectGrid: ({ schema }: any) => { + gridSchemas.push(schema); + return
; + }, +})); +vi.mock('@object-ui/plugin-form', () => ({ ObjectForm: () =>
})); + +const mockDataSource = () => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), +}); + +/** + * Render a non-grid view and return the schema `generateViewSchema` produced. + * `views` drives the view types `defaultViewType` cannot spell (`tree`). + */ +async function generatedSchema( + schema: Partial, + views?: any[], +): Promise { + rendered.length = 0; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; +} + +/** Render through the delegated `renderListView` slot and return its schema. */ +function delegatedSchema(schema: Partial): any { + const seen: any[] = []; + render( + { seen.push(s); return
; }} + />, + ); + return seen[0]; +} + +/** Render on the grid path and return the schema ObjectGrid was handed. */ +function forwardedGridSchema(schema: Partial): any { + gridSchemas.length = 0; + render( + , + ); + return gridSchemas[0]; +} + +const KANBAN = { defaultViewType: 'kanban' as const }; +const TREE_VIEWS = [{ id: 'tree-1', label: 'Tree', type: 'tree' as const }]; + +beforeEach(() => { + vi.clearAllMocks(); + rendered.length = 0; + gridSchemas.length = 0; +}); + +describe('kanban: a canonical table.columns reaches the cards (objectui#5269)', () => { + it('forwards table.columns as the kanban card fields', async () => { + // Before the fix this arrived as `cardFields: []` — the card fields the + // author declared were dropped at the forwarding hop, not at the renderer. + const s = await generatedSchema({ ...KANBAN, table: { columns: ['stage', 'owner'] } as any }); + expect(s.type).toBe('object-kanban'); + expect(s.cardFields).toEqual(['stage', 'owner']); + expect(s.fields).toEqual(['stage', 'owner']); + }); + + it('still forwards the deprecated table.fields', async () => { + // The alias must keep working — every view authored against today's docs + // writes it. (Green before AND after the fix: this pins the half that must + // NOT change, not the defect.) + const s = await generatedSchema({ ...KANBAN, table: { fields: ['stage'] } as any }); + expect(s.cardFields).toEqual(['stage']); + }); + + it('prefers the canonical table.columns over the deprecated table.fields', async () => { + const s = await generatedSchema({ + ...KANBAN, + table: { columns: ['stage'], fields: ['owner'] } as any, + }); + expect(s.cardFields).toEqual(['stage']); + }); + + it('keeps a named view outranking the table segment', async () => { + // Precedence is unchanged by this card and must stay that way: the two + // segments ahead of `table` still win. (Green on both legs — a guard on + // the fix, not evidence of it.) + const s = await generatedSchema({ + ...KANBAN, + listViews: { mine: { label: 'Mine', columns: ['assignee'] } }, + defaultListView: 'mine', + table: { columns: ['stage'] } as any, + } as any); + expect(s.cardFields).toEqual(['assignee']); + }); +}); + +describe('tree: a canonical table.columns reaches the flat columns (objectui#5269)', () => { + it('forwards table.columns as the tree view fields', async () => { + const s = await generatedSchema({ table: { columns: ['stage', 'owner'] } as any }, TREE_VIEWS); + expect(s.type).toBe('object-tree'); + expect(s.fields).toEqual(['stage', 'owner']); + }); + + it('still forwards the deprecated table.fields', async () => { + const s = await generatedSchema({ table: { fields: ['stage'] } as any }, TREE_VIEWS); + expect(s.fields).toEqual(['stage']); + }); +}); + +describe('delegated renderListView: canonical first, alias still working (objectui#5269)', () => { + it('hands over a canonical table.columns as the list-view columns', () => { + expect(delegatedSchema({ table: { columns: ['stage', 'owner'] } as any }).columns) + .toEqual(['stage', 'owner']); + }); + + it('hands over the ListColumn[] form of table.columns unchanged', () => { + // `ObjectGridSchema.columns` (and `list-view`'s own `columns`) are both + // `string[] | ListColumn[]`, so the object form is a declared shape on + // this slot and must not be flattened on the way through. + const cols = [{ field: 'stage', label: 'Stage', width: 200 }]; + expect(delegatedSchema({ table: { columns: cols } as any }).columns).toEqual(cols); + }); + + it('still hands over the deprecated table.fields', () => { + expect(delegatedSchema({ table: { fields: ['stage'] } as any }).columns).toEqual(['stage']); + }); + + it('prefers the canonical table.columns over the deprecated table.fields', () => { + expect(delegatedSchema({ table: { columns: ['stage'], fields: ['owner'] } as any }).columns) + .toEqual(['stage']); + }); + + it('keeps a named view outranking the table segment', () => { + const s = delegatedSchema({ + listViews: { mine: { label: 'Mine', columns: ['assignee'] } }, + defaultListView: 'mine', + table: { columns: ['stage'] } as any, + } as any); + expect(s.columns).toEqual(['assignee']); + }); +}); + +describe('grid path: unchanged by this card — the control', () => { + it('keeps emitting table.columns and table.fields in their own slots', () => { + // The grid path forwards BOTH slots and lets `ObjectGrid` arbitrate + // (objectui#5102's shape). This card does not touch it; the assertion is + // here so a future "make all three sites identical" tidy-up cannot + // collapse the two slots without a red test. + // + // The `undefined` half is counter-probed by the populated half in the same + // render: `fields: undefined` alone would also be what a probe that saw no + // grid schema at all would report. + const canonicalOnly = forwardedGridSchema({ table: { columns: ['stage'] } as any }); + expect(canonicalOnly.columns).toEqual(['stage']); + expect(canonicalOnly.fields).toBeUndefined(); + + const legacyOnly = forwardedGridSchema({ table: { fields: ['owner'] } as any }); + expect(legacyOnly.fields).toEqual(['owner']); + expect(legacyOnly.columns).toBeUndefined(); + }); +}); From 9421972191be7a28719440eab3817d72b7b41d52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:04:13 +0000 Subject: [PATCH 2/2] fix(plugin-view): resolve a ListColumn[] table.columns to names on the non-grid slot (#5269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-grid `baseProps` slot is a names slot — both segments ahead of the `table` one declare `string[]`, and `ObjectKanban` indexes the record by each entry — while `table.columns` is `string[] | ListColumn[]`. Forwarding the object form raw would have handed kanban a non-empty card field list naming nothing, which renders emptier than the bug this card fixes: a non-empty list suppresses ObjectKanban's `highlightFields` fallback. Same failure mode as objectui#5270 (a value in a slot whose declared shape it lacks), answered the same way, at the boundary. `columnIdentity` (@object-ui/core) is the fold ObjectGrid already applies to this very value on the grid path, so the two paths now resolve one authored `table.columns` identically. `undefined`, never `[]`, when nothing resolves — otherwise a truthy empty array would stop the `||` chain short of the deprecated `table.fields`. The delegated `list-view` slot declares the same union and keeps taking the value raw. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .../objectview-table-columns-non-grid-5269.md | 11 +++++ packages/plugin-view/src/ObjectView.tsx | 44 ++++++++++++++++++- ...ObjectView.tableColumnsForwarding.test.tsx | 36 +++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/.changeset/objectview-table-columns-non-grid-5269.md b/.changeset/objectview-table-columns-non-grid-5269.md index dd799fe21..d4f323736 100644 --- a/.changeset/objectview-table-columns-non-grid-5269.md +++ b/.changeset/objectview-table-columns-non-grid-5269.md @@ -25,3 +25,14 @@ card fields) and `object-tree` (its flat columns) consume the shared `object-gallery`, `object-calendar`, `object-timeline`, `object-gantt` and `object-map` read no field list off their schema at all, so the forwarded value is inert there — before this change and after it. + +One shape question the forwarding raised, answered at the boundary: +`table.columns` is `string[] | ListColumn[]`, and the non-grid slot is a +names slot (`ObjectKanban` indexes the record by each entry). The object form +is therefore resolved to field names there with `columnIdentity` — the same +fold `ObjectGrid` applies to this very value — so one authored `table.columns` +resolves identically on both paths, and a `ListColumn[]` cannot arrive as a +non-empty card field list naming nothing (which would suppress ObjectKanban's +`highlightFields` fallback and render emptier than the bug being fixed). The +delegated `list-view` slot declares the same union and keeps the value raw, so +an author's per-column `label` / `width` still reach the list renderer. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 4e8c44ae4..9e791a485 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -57,7 +57,7 @@ import { } from '@object-ui/components'; import { Plus } from 'lucide-react'; import { useObjectTranslation, createSafeTranslation } from '@object-ui/i18n'; -import { buildExpandFields, normalizeListViewSchema, mergeFilterNodes } from '@object-ui/core'; +import { buildExpandFields, normalizeListViewSchema, mergeFilterNodes, columnIdentity } from '@object-ui/core'; import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react'; import { ViewSwitcher } from './ViewSwitcher'; import { deriveRecordSurface } from './recordSurface'; @@ -114,6 +114,40 @@ function pickFlatMapConfig(mapConfig: unknown): Record { return Object.fromEntries(FLAT_MAP_CONFIG_KEYS.filter((key) => key in source).map((key) => [key, source[key]])); } +/** + * `table.columns` as a FIELD-NAME list — the shape the non-grid field slot + * declares (objectui#5269). + * + * `ObjectGridSchema.columns` is `string[] | ListColumn[]`, but the slot the + * non-grid branch forwards into is a names slot: both segments ahead of the + * `table` one declare `string[]` (`NamedListView.columns`, the `views` prop), + * and its consumers treat every entry as a field name — `ObjectKanban` indexes + * the record by it (`resolveKanbanCardFields` casts straight to `string[]`). + * Handing a `ListColumn[]` down raw would therefore arrive as a non-empty card + * field list naming nothing, which renders WORSE than the empty one this card + * fixes: `ObjectKanban` skips its `highlightFields` fallback whenever the list + * is non-empty. That is the objectui#5270 failure again — a value forwarded + * into a slot whose declared shape it does not have — and it is answered the + * same way, at the boundary. + * + * `columnIdentity` is the repo's single converged reader for "which field does + * this column entry name" (objectui#3104), and it is what `ObjectGrid` already + * applies to the SAME `table.columns` value on the grid path (its `$select` + * derivation) and what `ObjectTree` applies downstream. So this narrows a + * declared union to the branch this slot can hold; it does not widen the set + * of accepted spellings, and it keeps the two paths resolving one value the + * same way. + * + * `undefined` — never `[]` — when nothing resolves, so the `||` chain falls + * through to the deprecated `table.fields` instead of stopping on a truthy + * empty array. + */ +function tableColumnFieldNames(columns: unknown): string[] | undefined { + if (!Array.isArray(columns) || columns.length === 0) return undefined; + const names = columns.map(columnIdentity).filter((n): n is string => !!n); + return names.length > 0 ? names : undefined; +} + /** * Record-create verb, shared with the runtime object pages: both surfaces * resolve `console.objectView.new` ("New" / 新建) so the Studio grid toolbar @@ -864,8 +898,14 @@ export const ObjectView: React.FC = ({ // `object-calendar` / `object-timeline` / `object-gantt` / `object-map` // read NO field list off their schema at all, so the value is inert // there — before this change and after it. + // + // The `table` segment arrives through `tableColumnFieldNames` because + // THIS slot is a names slot and `table.columns` is a union — see that + // function for why raw forwarding would regress the `ListColumn[]` half. + // The delegated `list-view` slot below declares the same union, so it + // takes the value raw; each site gets the shape its slot declares. fields: currentNamedViewConfig?.columns || activeView?.columns - || schema.table?.columns || schema.table?.fields, + || tableColumnFieldNames(schema.table?.columns) || schema.table?.fields, className: 'h-full w-full', showSearch: activeView?.showSearch ?? schema.showSearch ?? false, showSort: activeView?.showSort ?? schema.showSort ?? false, diff --git a/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx index 05c74524a..b3111de8d 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx @@ -48,6 +48,16 @@ * (The card objectui#5269 names kanban / gallery / calendar as the affected * surfaces; the measurable set is kanban / tree / delegated.) * + * ## One asymmetry between the two changed sites + * + * `table.columns` is `string[] | ListColumn[]`. The delegated `list-view` + * slot declares that same union and `ListView` reads both, so it takes the + * value RAW — flattening there would throw away the width/label the author + * wrote. The non-grid `baseProps` slot is a names slot (both segments ahead of + * `table` declare `string[]`; `ObjectKanban` indexes records by each entry), so + * the `ListColumn[]` half is resolved to names with `columnIdentity` — the + * same fold `ObjectGrid` applies to this very value. Both halves are pinned. + * * ## Precedence * * Unchanged, and pinned below: the two segments ahead of `table` @@ -176,6 +186,32 @@ describe('kanban: a canonical table.columns reaches the cards (objectui#5269)', expect(s.cardFields).toEqual(['stage']); }); + it('resolves the ListColumn[] form of table.columns to field NAMES', async () => { + // `ObjectGridSchema.columns` is `string[] | ListColumn[]`, but this slot is + // a names slot: `resolveKanbanCardFields` casts to `string[]` and the card + // loop indexes the record by each entry. Forwarding the objects raw would + // produce a non-empty card field list naming nothing — and a non-empty list + // suppresses ObjectKanban's `highlightFields` fallback, so the cards would + // come out emptier than before this card. Resolved at the boundary with + // `columnIdentity`, the same fold ObjectGrid applies to this very value. + const s = await generatedSchema({ + ...KANBAN, + table: { columns: [{ field: 'stage', label: 'Stage', width: 120 }] } as any, + }); + expect(s.cardFields).toEqual(['stage']); + }); + + it('falls through to table.fields when no column entry names a field', async () => { + // The empty-array trap: `[] || x` is `[]`, so a `columns` whose entries all + // fail to resolve must yield `undefined`, not an empty list that stops the + // chain on a falsy-looking truthy value. + const s = await generatedSchema({ + ...KANBAN, + table: { columns: [{ label: 'no identity here' }], fields: ['owner'] } as any, + }); + expect(s.cardFields).toEqual(['owner']); + }); + it('keeps a named view outranking the table segment', async () => { // Precedence is unchanged by this card and must stay that way: the two // segments ahead of `table` still win. (Green on both legs — a guard on