Skip to content

Commit cd09a7b

Browse files
os-zhuangclaude
andauthored
refactor(views): ListView reads spec-canonical columns, legacy fields folded in one normalizer (#2890) (#2932)
`ListViewSchema` has been spec-derived since #2231, but the renderer still spoke objectui's own vocabulary. First rename closed: `fields` → `columns`. Legacy acceptance stays (stored view metadata carries `fields`) but moves into a single documented normalizer instead of per-read-site fallbacks: - `normalizeListViewSchema` (@object-ui/core) folds `fields` into `columns` (canonical wins) and drops the legacy key so a missed read-site fails loudly. It also absorbs the `viewType` renderability default ListView applied inline. Non-mutating, idempotent, returns its input by reference when nothing folds. - `ListView` normalizes once at the component boundary. That call site — not the zod schema — is the guarantee: nothing on the render path parses view metadata through zod, so a `z.preprocess` on `ListViewSchema` would never run. - Producers emit `columns`: ObjectView's `renderListView` payload, ObjectDataPage, InterfaceListPage and the `list-view` registry defaults were downgrading already-canonical `columns` config back to `fields`. Also fixes the filter builder's objectDef-not-loaded fallback, which resolved only `name`/`fieldName` and so produced unnamed entries for ListColumn objects. `fields` stays declared on the schema 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 now. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent b076050 commit cd09a7b

14 files changed

Lines changed: 310 additions & 51 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/plugin-list": minor
4+
"@object-ui/plugin-view": minor
5+
"@object-ui/app-shell": patch
6+
"@object-ui/types": patch
7+
---
8+
9+
refactor(views): ListView reads the spec-canonical `columns`, with legacy `fields` folded in one normalizer (#2890 scope A step 1)
10+
11+
`ListViewSchema` has been derived from `@objectstack/spec/ui` since #2231, but
12+
the renderer still spoke objectui's own vocabulary for the same concepts. First
13+
rename closed: **`fields``columns`**.
14+
15+
Legacy acceptance does not disappear — stored view metadata in user databases
16+
carries `fields` — but it now lives in exactly one place instead of being
17+
re-implemented per read-site:
18+
19+
- **New `normalizeListViewSchema` (`@object-ui/core`)** folds `fields` into
20+
`columns` (canonical wins when both are present) and drops the legacy key, so
21+
a read-site that was missed fails loudly instead of quietly taking the legacy
22+
path. It also absorbs the `viewType` renderability default ListView applied
23+
inline. Non-mutating, idempotent, and returns its input by reference when
24+
there is nothing to fold, so ListView's downstream memos keep a stable
25+
dependency identity.
26+
- **`ListView` normalizes once at the component boundary**, before anything
27+
reads the schema. This is what guarantees the fold runs: nothing on the render
28+
path parses view metadata through zod (the zod schemas serve the CLI
29+
validator, the VS Code extension and tests), so a `z.preprocess` on
30+
`ListViewSchema` — spec-side or local — would never execute.
31+
- **Producers emit `columns`**: `ObjectView`'s `renderListView` payload,
32+
`ObjectDataPage`, `InterfaceListPage` and the `list-view` registry defaults
33+
had been *downgrading* already-canonical `columns` config back to `fields`.
34+
35+
Two latent inconsistencies go away with it: the filter builder's
36+
objectDef-not-loaded fallback now resolves `ListColumn.field` (it read only
37+
`name`/`fieldName`, so object-form columns produced unnamed filter entries), and
38+
the column list no longer depends on which of the two keys a host happened to
39+
emit.
40+
41+
`fields` stays declared on `ListViewSchema` and in the drift guard's sanctioned
42+
set — it is still valid input, and `@objectstack/spec`'s `react-blocks.ts`
43+
sanctions it as the React-tier `<ListView fields>` prop — but it is input-only.

packages/app-shell/src/views/InterfaceListPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
365365
type: 'list-view' as const,
366366
objectName: objectDef.name,
367367
viewType: (allowed[0] ?? view.type ?? 'grid'),
368-
fields: columns,
368+
columns,
369369
...(filters.length ? { filters } : {}),
370370
...(sort?.length ? { sort } : {}),
371371
grouping: view.grouping,

packages/app-shell/src/views/ObjectDataPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ export function ObjectDataPage({ dataSource, objects }: any) {
217217
type: 'list-view' as const,
218218
objectName: objectDef.name,
219219
viewType: 'grid' as const,
220-
fields: columns,
220+
columns,
221221
...(urlFilters.length ? { filters: urlFilters } : {}),
222222
kanban,
223223
calendar,

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,4 @@ export * from './utils/dataset-format.js';
5454
export * from './utils/record-title.js';
5555
export * from './utils/export-filename.js';
5656
export * from './utils/reference-keys.js';
57+
export * from './utils/normalize-list-view.js';
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { normalizeListViewSchema } from '../normalize-list-view.js';
11+
12+
describe('normalizeListViewSchema (#2890)', () => {
13+
describe('fields → columns', () => {
14+
it('folds the legacy `fields` into the spec-canonical `columns`', () => {
15+
const out = normalizeListViewSchema({ type: 'list-view', viewType: 'grid', fields: ['name', 'stage'] });
16+
expect(out).toEqual({ type: 'list-view', viewType: 'grid', columns: ['name', 'stage'] });
17+
});
18+
19+
it('drops the legacy key so a missed read-site fails loudly instead of taking the legacy path', () => {
20+
const out = normalizeListViewSchema({ viewType: 'grid', fields: ['name'] }) as Record<string, unknown>;
21+
expect('fields' in out).toBe(false);
22+
});
23+
24+
it('lets the canonical key win when a payload carries both', () => {
25+
const out = normalizeListViewSchema({
26+
viewType: 'grid',
27+
columns: ['canonical'],
28+
fields: ['legacy'],
29+
}) as Record<string, unknown>;
30+
expect(out.columns).toEqual(['canonical']);
31+
expect('fields' in out).toBe(false);
32+
});
33+
34+
it('preserves ListColumn object entries, not just string columns', () => {
35+
const columns = [{ field: 'name', label: 'Name', width: 200 }];
36+
const out = normalizeListViewSchema({ viewType: 'grid', columns }) as Record<string, unknown>;
37+
expect(out.columns).toBe(columns);
38+
});
39+
40+
it('folds an empty `fields` array (an explicitly empty column set is not "absent")', () => {
41+
const out = normalizeListViewSchema({ viewType: 'grid', fields: [] }) as Record<string, unknown>;
42+
expect(out.columns).toEqual([]);
43+
});
44+
45+
it('ignores a non-array `fields` — malformed metadata must not become a column set', () => {
46+
const out = normalizeListViewSchema({ viewType: 'grid', fields: 'name' }) as Record<string, unknown>;
47+
expect(out.columns).toBeUndefined();
48+
expect(out.fields).toBe('name');
49+
});
50+
51+
it('is idempotent', () => {
52+
const once = normalizeListViewSchema({ viewType: 'grid', fields: ['name'] });
53+
const twice = normalizeListViewSchema(once);
54+
expect(twice).toEqual(once);
55+
expect(twice).toBe(once); // nothing left to fold → same reference
56+
});
57+
});
58+
59+
describe('viewType defaulting', () => {
60+
it('defaults a missing view kind to the renderable `grid`', () => {
61+
expect(normalizeListViewSchema({ type: 'list-view' })).toEqual({ type: 'list-view', viewType: 'grid' });
62+
});
63+
64+
it('maps the view CATEGORY `list` to `grid` (AI-authored metadata stores `list`)', () => {
65+
const out = normalizeListViewSchema({ viewType: 'list' }) as Record<string, unknown>;
66+
expect(out.viewType).toBe('grid');
67+
});
68+
69+
it('leaves an explicit renderable kind alone', () => {
70+
const out = normalizeListViewSchema({ viewType: 'kanban' }) as Record<string, unknown>;
71+
expect(out.viewType).toBe('kanban');
72+
});
73+
});
74+
75+
describe('reference stability', () => {
76+
it('returns the input by reference when there is nothing to fold', () => {
77+
// Load-bearing: ListView memoizes on this identity, so allocating a fresh
78+
// object on every render would re-run every downstream useMemo.
79+
const schema = { type: 'list-view', viewType: 'grid', columns: ['name'] };
80+
expect(normalizeListViewSchema(schema)).toBe(schema);
81+
});
82+
83+
it('does not mutate the input when it does fold', () => {
84+
const schema = { viewType: 'grid', fields: ['name'] };
85+
normalizeListViewSchema(schema);
86+
expect(schema).toEqual({ viewType: 'grid', fields: ['name'] });
87+
});
88+
89+
it('tolerates non-object input', () => {
90+
expect(normalizeListViewSchema(null)).toBeNull();
91+
expect(normalizeListViewSchema(undefined)).toBeUndefined();
92+
expect(normalizeListViewSchema('list-view')).toBe('list-view');
93+
});
94+
});
95+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* ObjectUI — list-view vocabulary canonicalization
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* ObjectUI's `list-view` node historically used a different vocabulary from
11+
* `@objectstack/spec` for the same concepts (`fields` where the spec says
12+
* `columns`, `viewType` where it says `type`, …). Issue #2231 closed the
13+
* type-level fork; #2890 closes the vocabulary fork.
14+
*
15+
* Stored view metadata in user databases still carries the legacy keys, so the
16+
* renderer cannot simply stop accepting them. Per AGENTS.md Commandment #0.1 the
17+
* answer is NOT a per-read-site `??` fallback — those fossilize a second de-facto
18+
* contract and drift apart (they already had: `ObjectGrid` preferred `columns`
19+
* in one branch and `fields` in another). Instead legacy acceptance lives HERE,
20+
* in one documented normalizer at the component boundary, mirroring
21+
* `normalizeSchemaReferenceKeys` (object schemas) and the spec's own
22+
* `normalizeVisibleWhen` / `normalizeFilterOperator` migration bridges.
23+
*
24+
* Note this cannot be a `z.preprocess` on `ListViewSchema`: nothing on the
25+
* render path parses view metadata through zod (the zod schemas are used by the
26+
* CLI validator, the VS Code extension and tests only), so a schema-level fold
27+
* would never run. The guarantee comes from the call site instead — `ListView`
28+
* normalizes before it reads anything.
29+
*
30+
* The fold is deliberately one-directional: the canonical key wins when both are
31+
* present, and the legacy key is REMOVED from the result so a read-site that was
32+
* missed fails loudly instead of quietly taking the legacy path. Like a spec
33+
* migration bridge, this is expected to be dropped in a future major once stored
34+
* metadata has been migrated.
35+
*
36+
* Non-mutating and allocation-frugal: returns the input by reference when there
37+
* is nothing to fold, so `ListView`'s downstream `useMemo`s keep a stable
38+
* dependency identity on the common (already-canonical) path.
39+
*
40+
* Currently folded:
41+
* - `fields` → `columns` (#2890 scope A step 1)
42+
* - `viewType`: a missing kind, or the view CATEGORY `'list'` that AI-authored
43+
* metadata stores and hosts forward verbatim, becomes the renderable `'grid'`
44+
* — otherwise it reaches the renderer's typeless default branch and shows as
45+
* a red "Unknown component type" box.
46+
*/
47+
export function normalizeListViewSchema<T>(schema: T): T {
48+
if (!schema || typeof schema !== 'object') return schema;
49+
const s = schema as Record<string, unknown>;
50+
51+
const legacyFields = s.fields;
52+
const foldColumns = Array.isArray(legacyFields);
53+
const viewType = s.viewType;
54+
const defaultViewKind = !viewType || viewType === 'list';
55+
if (!foldColumns && !defaultViewKind) return schema;
56+
57+
const next: Record<string, unknown> = { ...s };
58+
if (foldColumns) {
59+
if (!Array.isArray(next.columns)) next.columns = legacyFields;
60+
delete next.fields;
61+
}
62+
if (defaultViewKind) next.viewType = 'grid';
63+
return next as T;
64+
}

packages/plugin-list/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function ContactsView() {
5353
type: 'list-view',
5454
objectName: 'contacts',
5555
viewType: 'grid',
56-
fields: ['name', 'email', 'phone', 'company'],
56+
columns: ['name', 'email', 'phone', 'company'],
5757
sort: [{ field: 'name', order: 'asc' }],
5858
}}
5959
/>
@@ -73,7 +73,7 @@ are supported on the schema:
7373
type: 'list-view',
7474
objectName: 'tasks',
7575
viewType: 'grid',
76-
fields: ['title', 'status', 'assignee'],
76+
columns: ['title', 'status', 'assignee'],
7777
grouping: {
7878
fields: [
7979
{ field: 'status', order: 'asc', collapsed: false },
@@ -90,7 +90,7 @@ are supported on the schema:
9090
type: 'list-view',
9191
objectName: 'tasks',
9292
viewType: 'grid',
93-
fields: ['title', 'status'],
93+
columns: ['title', 'status'],
9494
groupBy: 'status',
9595
}}
9696
/>
@@ -107,7 +107,7 @@ grouping fields at runtime via the Group toolbar button.
107107
type: 'list-view',
108108
objectName: 'deals',
109109
viewType: 'kanban',
110-
fields: ['name', 'amount', 'stage', 'close_date'],
110+
columns: ['name', 'amount', 'stage', 'close_date'],
111111
options: {
112112
kanban: {
113113
groupField: 'stage',
@@ -134,7 +134,7 @@ grouping fields at runtime via the Group toolbar button.
134134
schema={{
135135
type: 'list-view',
136136
objectName: 'tasks',
137-
fields: ['title', 'status', 'priority'],
137+
columns: ['title', 'status', 'priority'],
138138
}}
139139
onViewChange={(view) => console.log('View changed to:', view)}
140140
onSearchChange={(search) => console.log('Search:', search)}
@@ -152,7 +152,10 @@ interface ListViewSchema {
152152
type: 'list-view';
153153
objectName: string;
154154
viewType?: 'grid' | 'kanban' | 'calendar' | 'gantt' | 'map' | 'chart';
155-
fields?: string[];
155+
/** Spec-canonical column list. The legacy `fields` alias is still accepted
156+
* on input (stored view metadata carries it) and folded into `columns` by
157+
* `normalizeListViewSchema` — but nothing reads it, so emit `columns`. */
158+
columns?: string[] | ListColumn[];
156159
filters?: Array<any[] | string>;
157160
sort?: Array<{ field: string; order: 'asc' | 'desc' }>;
158161
options?: {

0 commit comments

Comments
 (0)