diff --git a/.changeset/listview-columns-vocabulary.md b/.changeset/listview-columns-vocabulary.md new file mode 100644 index 000000000..c62050dc0 --- /dev/null +++ b/.changeset/listview-columns-vocabulary.md @@ -0,0 +1,43 @@ +--- +"@object-ui/core": minor +"@object-ui/plugin-list": minor +"@object-ui/plugin-view": minor +"@object-ui/app-shell": patch +"@object-ui/types": patch +--- + +refactor(views): ListView reads the spec-canonical `columns`, with legacy `fields` folded in one normalizer (#2890 scope A step 1) + +`ListViewSchema` has been derived from `@objectstack/spec/ui` since #2231, but +the renderer still spoke objectui's own vocabulary for the same concepts. First +rename closed: **`fields` → `columns`**. + +Legacy acceptance does not disappear — stored view metadata in user databases +carries `fields` — but it now lives in exactly one place instead of being +re-implemented per read-site: + +- **New `normalizeListViewSchema` (`@object-ui/core`)** folds `fields` into + `columns` (canonical wins when both are present) and drops the legacy key, so + a read-site that was missed fails loudly instead of quietly taking the legacy + path. It also absorbs the `viewType` renderability default ListView applied + inline. Non-mutating, idempotent, and returns its input by reference when + there is nothing to fold, so ListView's downstream memos keep a stable + dependency identity. +- **`ListView` normalizes once at the component boundary**, before anything + reads the schema. This is what guarantees the fold runs: nothing on the render + path parses view metadata through zod (the zod schemas serve the CLI + validator, the VS Code extension and tests), so a `z.preprocess` on + `ListViewSchema` — spec-side or local — would never execute. +- **Producers emit `columns`**: `ObjectView`'s `renderListView` payload, + `ObjectDataPage`, `InterfaceListPage` and the `list-view` registry defaults + had been *downgrading* already-canonical `columns` config back to `fields`. + +Two latent inconsistencies go away with it: the filter builder's +objectDef-not-loaded fallback now resolves `ListColumn.field` (it read only +`name`/`fieldName`, so object-form columns produced unnamed filter entries), and +the column list no longer depends on which of the two keys a host happened to +emit. + +`fields` stays declared on `ListViewSchema` and in the drift guard's sanctioned +set — it is still valid input, and `@objectstack/spec`'s `react-blocks.ts` +sanctions it as the React-tier `` prop — but it is input-only. diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index caaaf9156..6f11ebced 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -365,7 +365,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit type: 'list-view' as const, objectName: objectDef.name, viewType: (allowed[0] ?? view.type ?? 'grid'), - fields: columns, + columns, ...(filters.length ? { filters } : {}), ...(sort?.length ? { sort } : {}), grouping: view.grouping, diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index 6e49bb409..257ab2b62 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -217,7 +217,7 @@ export function ObjectDataPage({ dataSource, objects }: any) { type: 'list-view' as const, objectName: objectDef.name, viewType: 'grid' as const, - fields: columns, + columns, ...(urlFilters.length ? { filters: urlFilters } : {}), kanban, calendar, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 192f60d17..dde0dedec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,3 +54,4 @@ export * from './utils/dataset-format.js'; export * from './utils/record-title.js'; export * from './utils/export-filename.js'; export * from './utils/reference-keys.js'; +export * from './utils/normalize-list-view.js'; diff --git a/packages/core/src/utils/__tests__/normalize-list-view.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.test.ts new file mode 100644 index 000000000..0df34d1e2 --- /dev/null +++ b/packages/core/src/utils/__tests__/normalize-list-view.test.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeListViewSchema } from '../normalize-list-view.js'; + +describe('normalizeListViewSchema (#2890)', () => { + describe('fields → columns', () => { + it('folds the legacy `fields` into the spec-canonical `columns`', () => { + const out = normalizeListViewSchema({ type: 'list-view', viewType: 'grid', fields: ['name', 'stage'] }); + expect(out).toEqual({ type: 'list-view', viewType: 'grid', columns: ['name', 'stage'] }); + }); + + it('drops the legacy key so a missed read-site fails loudly instead of taking the legacy path', () => { + const out = normalizeListViewSchema({ viewType: 'grid', fields: ['name'] }) as Record; + expect('fields' in out).toBe(false); + }); + + it('lets the canonical key win when a payload carries both', () => { + const out = normalizeListViewSchema({ + viewType: 'grid', + columns: ['canonical'], + fields: ['legacy'], + }) as Record; + expect(out.columns).toEqual(['canonical']); + expect('fields' in out).toBe(false); + }); + + it('preserves ListColumn object entries, not just string columns', () => { + const columns = [{ field: 'name', label: 'Name', width: 200 }]; + const out = normalizeListViewSchema({ viewType: 'grid', columns }) as Record; + expect(out.columns).toBe(columns); + }); + + it('folds an empty `fields` array (an explicitly empty column set is not "absent")', () => { + const out = normalizeListViewSchema({ viewType: 'grid', fields: [] }) as Record; + expect(out.columns).toEqual([]); + }); + + it('ignores a non-array `fields` — malformed metadata must not become a column set', () => { + const out = normalizeListViewSchema({ viewType: 'grid', fields: 'name' }) as Record; + expect(out.columns).toBeUndefined(); + expect(out.fields).toBe('name'); + }); + + it('is idempotent', () => { + const once = normalizeListViewSchema({ viewType: 'grid', fields: ['name'] }); + const twice = normalizeListViewSchema(once); + expect(twice).toEqual(once); + expect(twice).toBe(once); // nothing left to fold → same reference + }); + }); + + describe('viewType defaulting', () => { + it('defaults a missing view kind to the renderable `grid`', () => { + expect(normalizeListViewSchema({ type: 'list-view' })).toEqual({ type: 'list-view', viewType: 'grid' }); + }); + + it('maps the view CATEGORY `list` to `grid` (AI-authored metadata stores `list`)', () => { + const out = normalizeListViewSchema({ viewType: 'list' }) as Record; + expect(out.viewType).toBe('grid'); + }); + + it('leaves an explicit renderable kind alone', () => { + const out = normalizeListViewSchema({ viewType: 'kanban' }) as Record; + expect(out.viewType).toBe('kanban'); + }); + }); + + describe('reference stability', () => { + it('returns the input by reference when there is nothing to fold', () => { + // Load-bearing: ListView memoizes on this identity, so allocating a fresh + // object on every render would re-run every downstream useMemo. + const schema = { type: 'list-view', viewType: 'grid', columns: ['name'] }; + expect(normalizeListViewSchema(schema)).toBe(schema); + }); + + it('does not mutate the input when it does fold', () => { + const schema = { viewType: 'grid', fields: ['name'] }; + normalizeListViewSchema(schema); + expect(schema).toEqual({ viewType: 'grid', fields: ['name'] }); + }); + + it('tolerates non-object input', () => { + expect(normalizeListViewSchema(null)).toBeNull(); + expect(normalizeListViewSchema(undefined)).toBeUndefined(); + expect(normalizeListViewSchema('list-view')).toBe('list-view'); + }); + }); +}); diff --git a/packages/core/src/utils/normalize-list-view.ts b/packages/core/src/utils/normalize-list-view.ts new file mode 100644 index 000000000..f22130ff3 --- /dev/null +++ b/packages/core/src/utils/normalize-list-view.ts @@ -0,0 +1,64 @@ +/** + * ObjectUI — list-view vocabulary canonicalization + * 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's `list-view` node historically used a different vocabulary from + * `@objectstack/spec` for the same concepts (`fields` where the spec says + * `columns`, `viewType` where it says `type`, …). Issue #2231 closed the + * type-level fork; #2890 closes the vocabulary fork. + * + * Stored view metadata in user databases still carries the legacy keys, so the + * renderer cannot simply stop accepting them. Per AGENTS.md Commandment #0.1 the + * answer is NOT a per-read-site `??` fallback — those fossilize a second de-facto + * contract and drift apart (they already had: `ObjectGrid` preferred `columns` + * in one branch and `fields` in another). Instead legacy acceptance lives HERE, + * in one documented normalizer at the component boundary, mirroring + * `normalizeSchemaReferenceKeys` (object schemas) and the spec's own + * `normalizeVisibleWhen` / `normalizeFilterOperator` migration bridges. + * + * Note this cannot be a `z.preprocess` on `ListViewSchema`: nothing on the + * render path parses view metadata through zod (the zod schemas are used by the + * CLI validator, the VS Code extension and tests only), so a schema-level fold + * would never run. The guarantee comes from the call site instead — `ListView` + * normalizes before it reads anything. + * + * The fold is deliberately one-directional: the canonical key wins when both are + * present, and the legacy key is REMOVED from the result so a read-site that was + * missed fails loudly instead of quietly taking the legacy path. Like a spec + * migration bridge, this is expected to be dropped in a future major once stored + * metadata has been migrated. + * + * Non-mutating and allocation-frugal: returns the input by reference when there + * is nothing to fold, so `ListView`'s downstream `useMemo`s keep a stable + * dependency identity on the common (already-canonical) path. + * + * Currently folded: + * - `fields` → `columns` (#2890 scope A step 1) + * - `viewType`: a missing kind, or the view CATEGORY `'list'` that AI-authored + * metadata stores and hosts forward verbatim, becomes the renderable `'grid'` + * — otherwise it reaches the renderer's typeless default branch and shows as + * a red "Unknown component type" box. + */ +export function normalizeListViewSchema(schema: T): T { + if (!schema || typeof schema !== 'object') return schema; + const s = schema as Record; + + const legacyFields = s.fields; + const foldColumns = Array.isArray(legacyFields); + const viewType = s.viewType; + const defaultViewKind = !viewType || viewType === 'list'; + if (!foldColumns && !defaultViewKind) return schema; + + const next: Record = { ...s }; + if (foldColumns) { + if (!Array.isArray(next.columns)) next.columns = legacyFields; + delete next.fields; + } + if (defaultViewKind) next.viewType = 'grid'; + return next as T; +} diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 52a159c8c..384678b39 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -53,7 +53,7 @@ function ContactsView() { type: 'list-view', objectName: 'contacts', viewType: 'grid', - fields: ['name', 'email', 'phone', 'company'], + columns: ['name', 'email', 'phone', 'company'], sort: [{ field: 'name', order: 'asc' }], }} /> @@ -73,7 +73,7 @@ are supported on the schema: type: 'list-view', objectName: 'tasks', viewType: 'grid', - fields: ['title', 'status', 'assignee'], + columns: ['title', 'status', 'assignee'], grouping: { fields: [ { field: 'status', order: 'asc', collapsed: false }, @@ -90,7 +90,7 @@ are supported on the schema: type: 'list-view', objectName: 'tasks', viewType: 'grid', - fields: ['title', 'status'], + columns: ['title', 'status'], groupBy: 'status', }} /> @@ -107,7 +107,7 @@ grouping fields at runtime via the Group toolbar button. type: 'list-view', objectName: 'deals', viewType: 'kanban', - fields: ['name', 'amount', 'stage', 'close_date'], + columns: ['name', 'amount', 'stage', 'close_date'], options: { kanban: { groupField: 'stage', @@ -134,7 +134,7 @@ grouping fields at runtime via the Group toolbar button. schema={{ type: 'list-view', objectName: 'tasks', - fields: ['title', 'status', 'priority'], + columns: ['title', 'status', 'priority'], }} onViewChange={(view) => console.log('View changed to:', view)} onSearchChange={(search) => console.log('Search:', search)} @@ -152,7 +152,10 @@ interface ListViewSchema { type: 'list-view'; objectName: string; viewType?: 'grid' | 'kanban' | 'calendar' | 'gantt' | 'map' | 'chart'; - fields?: string[]; + /** Spec-canonical column list. The legacy `fields` alias is still accepted + * on input (stored view metadata carries it) and folded into `columns` by + * `normalizeListViewSchema` — but nothing reads it, so emit `columns`. */ + columns?: string[] | ListColumn[]; filters?: Array; sort?: Array<{ field: string; order: 'asc' | 'desc' }>; options?: { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 2db3d1d73..136c7c7b8 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -19,7 +19,7 @@ import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveCrudAffordances } from '@object-ui/core'; +import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveCrudAffordances, normalizeListViewSchema } from '@object-ui/core'; import { useObjectTranslation, useObjectLabel, useSafeFieldLabel, createSafeTranslation } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; @@ -335,22 +335,16 @@ export const ListView = React.forwardRef(({ const { fieldLabel: resolveFieldLabel, actionLabel: resolveActionLabel, objectLabel: resolveObjectLabel } = useListFieldLabel(); const { translateOptions } = useSafeFieldLabel(); - // Kernel level default: Ensure viewType is always a RENDERABLE kind. - // Two inputs must land on 'grid': a missing viewType, and the view-metadata - // kind `'list'` (AI-authored views store `type/viewKind: 'list'`, which hosts - // forward verbatim) — 'list' names the view CATEGORY, not a renderer, and - // letting it through used to hit the typeless default branch below and - // render as a red "Unknown component type" box. - // Perf: only allocate a new object when normalization is actually needed, - // otherwise return propSchema as-is so downstream useMemos see a stable - // reference when callers already provide a renderable viewType (the common case). - const schema = React.useMemo( - () => - propSchema.viewType && (propSchema.viewType as string) !== 'list' - ? propSchema - : { ...propSchema, viewType: 'grid' }, - [propSchema], - ); + // Canonicalize the view vocabulary ONCE, here, before anything reads it + // (#2890): the legacy `fields` folds into the spec's `columns`, and `viewType` + // is defaulted to a RENDERABLE kind. Nothing on this path parses the schema + // through zod, so this call site — not `ListViewSchema` — is what guarantees + // the fold runs. See `normalizeListViewSchema` for why the legacy key is + // dropped rather than dual-read. + // Perf: the normalizer returns propSchema by reference when there is nothing + // to fold, so downstream useMemos keep a stable dependency identity on the + // already-canonical path (the common case). + const schema = React.useMemo(() => normalizeListViewSchema(propSchema), [propSchema]); // Convenience: resolve field label with schema.objectName pre-bound const tFieldLabel = React.useCallback( @@ -776,7 +770,7 @@ export const ListView = React.forwardRef(({ // Auto-compute $expand fields from objectDef (lookup / master_detail). // - // Important: include not only the user-declared `schema.fields` (table + // Important: include not only the user-declared `schema.columns` (table // columns) but also the runtime fields used by alternate view types // (kanban cardFields, calendar dateField, gallery coverField, etc.). // Otherwise a kanban whose card shows `account` would request @@ -785,8 +779,8 @@ export const ListView = React.forwardRef(({ // list view shows "Initech Solutions" but kanban used to show // "8UY9zHWBfjYjYor4" for the same field. const expandFields = React.useMemo(() => { - const baseColumns = Array.isArray(schema.fields) - ? (schema.fields as any[]) + const baseColumns = Array.isArray(schema.columns) + ? (schema.columns as any[]) .map((f) => (typeof f === 'string' ? f : f?.field)) .filter((v): v is string => typeof v === 'string' && v.length > 0) : []; @@ -826,7 +820,7 @@ export const ListView = React.forwardRef(({ return buildExpandFields(objectDef?.fields, augmented); }, [ objectDef?.fields, - schema.fields, + schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, @@ -956,8 +950,8 @@ export const ListView = React.forwardRef(({ // boundary even though the UI hides it — server-side trust must // never be defeated by what the client requests. const selectFields = (() => { - const rawCols = Array.isArray(schema.fields) - ? (schema.fields as any[]) + const rawCols = Array.isArray(schema.columns) + ? (schema.columns as any[]) .map(f => (typeof f === 'string' ? f : f?.field)) .filter((v): v is string => typeof v === 'string' && v.length > 0) : []; @@ -1266,12 +1260,9 @@ export const ListView = React.forwardRef(({ // effect so $select can be gated server-side too.) // Apply hiddenFields and fieldOrder to produce effective fields const effectiveFields = React.useMemo(() => { - let fields = schema.fields || []; - - // Defensive: ensure fields is an array of strings/objects - if (!Array.isArray(fields)) { - fields = []; - } + // Defensive: `columns` is `string[] | ListColumn[]`, but metadata is + // user-authored — anything non-array degrades to "no declared columns". + let fields: any[] = Array.isArray(schema.columns) ? (schema.columns as any[]) : []; // FLS: drop columns the current user cannot read. if (perms?.isLoaded && schema.objectName) { @@ -1303,7 +1294,7 @@ export const ListView = React.forwardRef(({ } return fields; - }, [schema.fields, schema.objectName, hiddenFields, schema.fieldOrder, perms]); + }, [schema.columns, schema.objectName, hiddenFields, schema.fieldOrder, perms]); // Generate the appropriate view component schema const viewComponentSchema = React.useMemo(() => { @@ -1554,10 +1545,12 @@ export const ListView = React.forwardRef(({ }; if (!objectDef?.fields) { - // Fallback to schema fields if objectDef not loaded yet - fields = (schema.fields || []).map((f: any) => { + // Fallback to the declared columns if objectDef not loaded yet + fields = (Array.isArray(schema.columns) ? (schema.columns as any[]) : []).map((f: any) => { if (typeof f === 'string') return { value: f, label: f, type: 'text' }; - const fieldName = f.name || f.fieldName; + // `field` is the spec's ListColumn key; `name`/`fieldName` are the + // shapes hosts pass through from stored metadata. + const fieldName = f.name || f.fieldName || f.field; return { value: fieldName, label: tFieldLabel(fieldName, f.label || f.name), @@ -1587,7 +1580,7 @@ export const ListView = React.forwardRef(({ } return fields; - }, [objectDef, schema.fields, schema.filterableFields, schema.objectName, tFieldLabel, translateOptions]); + }, [objectDef, schema.columns, schema.filterableFields, schema.objectName, tFieldLabel, translateOptions]); // Export handler const handleExport = React.useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => { @@ -1729,7 +1722,7 @@ export const ListView = React.forwardRef(({ // All available fields for hide/show (with i18n) const allFields = React.useMemo(() => { - return (schema.fields || []).map((f: any) => { + return (Array.isArray(schema.columns) ? (schema.columns as any[]) : []).map((f: any) => { if (typeof f === 'string') { return { name: f, label: tFieldLabel(f, f) }; } @@ -1737,7 +1730,7 @@ export const ListView = React.forwardRef(({ const rawLabel = f.label || f.name || f.field; return { name, label: tFieldLabel(name, rawLabel) }; }); - }, [schema.fields, tFieldLabel]); + }, [schema.columns, tFieldLabel]); return (
{ + // ListView reads the spec-canonical `columns`. The legacy `fields` is folded + // into it by `normalizeListViewSchema` at the component boundary — the ONE + // place legacy acceptance lives, so no read-site dual-reads. `$select` is the + // observable: it is built from the columns the view actually shows. + const lastSelect = (): string[] | undefined => + mockDataSource.find.mock.calls.at(-1)?.[1]?.$select as string[] | undefined; + + const renderWithColumns = async (extra: Record) => { + const schema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + ...extra, + } as unknown as ListViewSchema; + renderWithProvider(); + await vi.waitFor(() => expect(mockDataSource.find).toHaveBeenCalled()); + }; + + beforeEach(() => { + mockDataSource.find.mockClear(); + mockDataSource.find.mockResolvedValue([]); + }); + + it('projects the spec-canonical `columns`', async () => { + await renderWithColumns({ columns: ['name', 'email'] }); + expect(lastSelect()).toEqual(expect.arrayContaining(['name', 'email'])); + }); + + it('projects `columns` given as ListColumn objects, not just strings', async () => { + await renderWithColumns({ columns: [{ field: 'name' }, { field: 'email' }] }); + expect(lastSelect()).toEqual(expect.arrayContaining(['name', 'email'])); + }); + + it('still honors the legacy `fields` — stored view metadata carries it', async () => { + await renderWithColumns({ fields: ['name', 'email'] }); + expect(lastSelect()).toEqual(expect.arrayContaining(['name', 'email'])); + }); + + it('lets `columns` win when a view carries both', async () => { + await renderWithColumns({ columns: ['name'], fields: ['email'] }); + const selected = lastSelect(); + expect(selected).toEqual(expect.arrayContaining(['name'])); + expect(selected).not.toContain('email'); + }); +}); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 3112ea92a..b5f5798b8 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -39,7 +39,7 @@ ComponentRegistry.register('list-view', ListView, { { label: 'Gantt', value: 'gantt' }, { label: 'Map', value: 'map' }, ], defaultValue: 'grid' }, - { name: 'fields', type: 'array', label: 'Fields' }, + { name: 'columns', type: 'array', label: 'Columns' }, { name: 'filters', type: 'array', label: 'Filters' }, { name: 'sort', type: 'array', label: 'Sort' }, { name: 'options', type: 'object', label: 'View Options' }, @@ -47,7 +47,7 @@ ComponentRegistry.register('list-view', ListView, { defaultProps: { objectName: '', viewType: 'grid', - fields: [], + columns: [], filters: [], sort: [], options: {}, @@ -80,7 +80,7 @@ ComponentRegistry.register('list', ListView, { { label: 'Gantt', value: 'gantt' }, { label: 'Map', value: 'map' }, ], defaultValue: 'grid' }, - { name: 'fields', type: 'array', label: 'Fields' }, + { name: 'columns', type: 'array', label: 'Columns' }, { name: 'filters', type: 'array', label: 'Filters' }, { name: 'sort', type: 'array', label: 'Sort' }, { name: 'options', type: 'object', label: 'View Options' }, diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index a1899ecee..8cbdb0c79 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1003,7 +1003,10 @@ export const ObjectView: React.FC = ({ // Active view's display label — ListView appends it to export // download filenames. label: (currentNamedViewConfig as any)?.label ?? activeView?.label, - fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields, + // 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, filters: mergedFilters, sort: mergedSort, // Propagate appearance/view-config properties for live preview diff --git a/packages/plugin-view/src/__tests__/ObjectView.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.test.tsx index 7a78d77ce..52697d182 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.test.tsx @@ -577,7 +577,9 @@ describe('ObjectView', () => { expect(screen.getByTestId('custom-list')).toBeInTheDocument(); const firstCallSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema; - expect(firstCallSchema?.fields).toEqual(['name']); + // Spec-canonical `columns` (#2890) — the view configs are already + // `columns`-keyed, so the payload no longer downgrades them to `fields`. + expect(firstCallSchema?.columns).toEqual(['name']); // Update views — simulate live preview change const updatedViews = [ @@ -597,7 +599,7 @@ describe('ObjectView', () => { // renderListView should have been called again with the updated columns const lastCallIndex = renderListViewSpy.mock.calls.length - 1; const lastCallSchema = renderListViewSpy.mock.calls[lastCallIndex]?.[0]?.schema; - expect(lastCallSchema?.fields).toEqual(['name', 'email', 'status']); + expect(lastCallSchema?.columns).toEqual(['name', 'email', 'status']); }); it('should pass showSort=false through schema to suppress sort UI', async () => { diff --git a/packages/types/src/__tests__/list-view-spec-parity.test.ts b/packages/types/src/__tests__/list-view-spec-parity.test.ts index faa5904eb..fff31af3e 100644 --- a/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -72,7 +72,11 @@ const SANCTIONED_LOCAL = new Set([ 'objectName', // renamed spec `type` (view-kind enum); `type` itself is the component discriminator 'viewType', - // legacy aliases for spec `columns` / `filter` + // legacy aliases for spec `columns` / `filter`. `fields` is INPUT-ONLY since + // #2890: `normalizeListViewSchema` (@object-ui/core) folds it into `columns` + // at the ListView boundary and no renderer reads it. It stays declared here + // so stored metadata keeps validating and so the spec's react-blocks + // `` prop keeps a schema anchor. 'fields', 'filters', // legacy toolbar visibility flags (spec-canonical: `userActions`) diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 5b78d07ef..5c2badacd 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -381,6 +381,10 @@ export const ListViewSchema = BaseSchema viewType: ViewKindEnum.optional().describe('View Type'), // Relaxed spec `columns` (spec requires it) + legacy `fields` alias for string[] columns. columns: z.union([z.array(z.string()), z.array(ListColumnSchema)]).optional().describe('Columns definition'), + // Legacy alias for `columns`, still accepted because stored view metadata + // carries it. NO renderer reads it: `normalizeListViewSchema` + // (`@object-ui/core`) folds it into `columns` at the ListView boundary + // (#2890). Producers must emit `columns`. fields: z.array(z.string()).optional().describe('Legacy alias for string[] columns'), // Legacy tuple/CEL filter format (spec-canonical `filter` is imported above). filters: z.array(z.union([z.array(z.any()), z.string()])).optional().describe('Filter conditions (legacy)'),