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
43 changes: 43 additions & 0 deletions .changeset/listview-columns-vocabulary.md
Original file line number Diff line number Diff line change
@@ -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 `<ListView fields>` prop — but it is input-only.
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
95 changes: 95 additions & 0 deletions packages/core/src/utils/__tests__/normalize-list-view.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(out.viewType).toBe('grid');
});

it('leaves an explicit renderable kind alone', () => {
const out = normalizeListViewSchema({ viewType: 'kanban' }) as Record<string, unknown>;
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');
});
});
});
64 changes: 64 additions & 0 deletions packages/core/src/utils/normalize-list-view.ts
Original file line number Diff line number Diff line change
@@ -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<T>(schema: T): T {
if (!schema || typeof schema !== 'object') return schema;
const s = schema as Record<string, unknown>;

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<string, unknown> = { ...s };
if (foldColumns) {
if (!Array.isArray(next.columns)) next.columns = legacyFields;
delete next.fields;
}
if (defaultViewKind) next.viewType = 'grid';
return next as T;
}
15 changes: 9 additions & 6 deletions packages/plugin-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
}}
/>
Expand All @@ -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 },
Expand All @@ -90,7 +90,7 @@ are supported on the schema:
type: 'list-view',
objectName: 'tasks',
viewType: 'grid',
fields: ['title', 'status'],
columns: ['title', 'status'],
groupBy: 'status',
}}
/>
Expand All @@ -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',
Expand All @@ -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)}
Expand All @@ -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<any[] | string>;
sort?: Array<{ field: string; order: 'asc' | 'desc' }>;
options?: {
Expand Down
Loading
Loading