Skip to content

Commit 5319bf1

Browse files
os-zhuangclaude
andauthored
feat(views): the list toolbar speaks one vocabulary — userActions (#2890) (#2948)
The seven bare `show*` toolbar flags fold into the spec's `userActions` and the renderer reads nothing else; `showDescription` folds into `appearance.showDescription` at the same boundary. The last three — `group` / `hideFields` / `rowColor` — are NEW keys, and they close a capability hole rather than renaming one. The spec documents `userActions` as "which interactive actions are available to users in the view toolbar — each boolean toggles the corresponding toolbar element on/off", and already carries `rowHeight` (objectui's `showDensity` under its spec name). Grouping, column visibility and row coloring are the same kind of toggle: the spec models all three as configuration (`grouping`, `hiddenFields`, `rowColor`) but has no "may the user change it" switch for any of them. That gap was visible in the product. With no `userActions` key to read, the two list surfaces hardcoded OPPOSITE policies — InterfaceListPage pinned all three off, ObjectDataPage pinned two on — and an interface-page author could not turn grouping back on for end users at all. Both now express policy as `userActions` defaults, which an author can override. Until the keys land upstream, @object-ui/types carries them as a documented `.extend()` on `UserActionsConfigSchema`, the shape `ListColumnSchema` already uses while waiting on objectstack#3761. The spec schema is not `.strict()`, so before this an author writing `userActions: { group: false }` had it silently stripped — valid on parse, no effect at render. Defaults unchanged and deliberately asymmetric: search/sort/filter/rowHeight/ group on unless turned off, hideFields/rowColor off unless turned on. Unifying them would grow two buttons on every existing view — its own product decision, not something to smuggle into a vocabulary migration. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffe9270 commit 5319bf1

11 files changed

Lines changed: 338 additions & 57 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/types": minor
4+
"@object-ui/plugin-list": minor
5+
"@object-ui/plugin-view": minor
6+
"@object-ui/app-shell": minor
7+
---
8+
9+
feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890 scope A step 3)
10+
11+
The seven bare `show*` toolbar flags fold into the spec's `userActions`, and the
12+
renderer reads nothing else. `showDescription` folds into
13+
`appearance.showDescription` at the same boundary.
14+
15+
| legacy | canonical |
16+
| :--- | :--- |
17+
| `showSearch` / `showSort` / `showFilters` / `showDensity` | `userActions.search` / `.sort` / `.filter` / `.rowHeight` |
18+
| `showGroup` / `showHideFields` / `showColor` | `userActions.group` / `.hideFields` / `.rowColor` |
19+
| `showDescription` | `appearance.showDescription` |
20+
21+
**The last three are new keys, and they close a capability hole rather than just
22+
renaming one.** `@objectstack/spec`'s `UserActionsConfigSchema` documents itself
23+
as "which interactive actions are available to users in the view toolbar — each
24+
boolean toggles the corresponding toolbar element on/off", and already carries
25+
`rowHeight` (objectui's `showDensity` under its spec name). Grouping, column
26+
visibility and row coloring are the same kind of toggle: the spec models all
27+
three as *configuration* (`grouping`, `hiddenFields`, `rowColor`) but has no
28+
"may the user change it" switch for any of them.
29+
30+
The consequence was visible in the product. With no `userActions` key to read,
31+
the two list surfaces **hardcoded opposite policies**: `InterfaceListPage` (the
32+
author-curated interface page) pinned all three OFF, `ObjectDataPage` pinned two
33+
ON — and an interface-page author could not turn grouping back on for end users
34+
at all. Both surfaces now express their policy as `userActions` defaults, which
35+
an author can override.
36+
37+
Until the keys land in `@objectstack/spec`, `@object-ui/types` carries them as a
38+
documented `.extend()` on `UserActionsConfigSchema` (the same shape
39+
`ListColumnSchema` uses while waiting on objectstack#3761); it collapses into a
40+
plain re-export once they do. Note the spec schema is not `.strict()`, so before
41+
this an author writing `userActions: { group: false }` had it **silently
42+
stripped** — valid on parse, no effect at render.
43+
44+
Defaults are unchanged and deliberately asymmetric, matching what these flags
45+
have always done: `search` / `sort` / `filter` / `rowHeight` / `group` are on
46+
unless turned off; `hideFields` / `rowColor` are off unless turned on. Making
47+
them uniform would grow two buttons on every existing view, so it is left as its
48+
own product decision rather than smuggled into a vocabulary migration.
49+
50+
Also drops a dead relay in app-shell's `ObjectView`, which forwarded
51+
`showDescription` onto the node although `ListView` has only ever read
52+
`appearance.showDescription`.

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -390,16 +390,25 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
390390
// panel's "Add Record" config silently did nothing at runtime.
391391
addRecord: cfg.addRecord,
392392

393-
// userActions toggles → toolbar flags. Interface mode is closed by
394-
// default: the advanced filter builder and view-management tools are
395-
// only present when the author opted in.
396-
showSearch: userActions.search !== false,
397-
showSort: userActions.sort !== false,
398-
showFilters: userActions.filter === true,
399-
showDensity: userActions.rowHeight === true,
400-
showHideFields: false,
401-
showGroup: false,
402-
showColor: false,
393+
// userActions passes straight through to the toolbar (#2890) — this used
394+
// to unpack it into bare `show*` flags, which is why the three toggles
395+
// with no `userActions` key (group / hideFields / rowColor) were HARDCODED
396+
// off here and hardcoded on in ObjectDataPage: two surfaces, two opposite
397+
// policies, neither author-controllable. They are author-controllable now.
398+
//
399+
// Interface mode stays closed BY DEFAULT — the advanced filter builder,
400+
// density and the view-management tools are present only when the author
401+
// opted in — but "closed by default" is expressed as a default here, not
402+
// as an unreachable constant.
403+
userActions: {
404+
search: userActions.search !== false,
405+
sort: userActions.sort !== false,
406+
filter: userActions.filter === true,
407+
rowHeight: userActions.rowHeight === true,
408+
group: userActions.group === true,
409+
hideFields: userActions.hideFields === true,
410+
rowColor: userActions.rowColor === true,
411+
},
403412
allowExport: false,
404413
// Inline record editing is a page-authored property: a list block opts in
405414
// via `userActions.editInline` (default off). When on, clicking a cell

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,16 @@ export function ObjectDataPage({ dataSource, objects }: any) {
226226
appearance: { allowedVisualizations },
227227
showViewSwitcher: allowedVisualizations.length > 1,
228228
// Full list capability — this surface trades the saved-view anchor for
229-
// the complete toolbar, NOT for a reduced one.
230-
showSearch: true,
231-
showSort: true,
232-
showFilters: true,
233-
showGroup: true,
234-
showHideFields: true,
235-
showDensity: true,
229+
// the complete toolbar, NOT for a reduced one. (#2890: expressed as
230+
// `userActions`, the one vocabulary, instead of bare `show*` flags.)
231+
userActions: {
232+
search: true,
233+
sort: true,
234+
filter: true,
235+
rowHeight: true,
236+
group: true,
237+
hideFields: true,
238+
},
236239
showRecordCount: true,
237240
// Deliberately NO onSortChange/onFilterChange persistence hooks: this
238241
// surface never writes back to any saved view (#2251).

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react';
1313
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
14-
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, type FilterTokenScope } from '@object-ui/core';
14+
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core';
1515
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
1616
import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage';
1717
const ObjectChart = lazy(() =>
@@ -1358,14 +1358,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
13581358
// whitelist with capability-resolvable types.
13591359
showViewSwitcher:
13601360
((viewDef.appearance ?? listSchema.appearance)?.allowedVisualizations?.length ?? 0) > 1,
1361-
// Propagate toolbar/display flags for all view types
1362-
showSearch: viewDef.showSearch ?? listSchema.showSearch,
1363-
showSort: viewDef.showSort ?? listSchema.showSort,
1364-
showFilters: viewDef.showFilters ?? listSchema.showFilters,
1365-
showHideFields: viewDef.showHideFields ?? listSchema.showHideFields,
1366-
showGroup: viewDef.showGroup ?? listSchema.showGroup,
1367-
showColor: viewDef.showColor ?? listSchema.showColor,
1368-
showDensity: viewDef.showDensity ?? listSchema.showDensity,
1361+
// Toolbar policy — one vocabulary (#2890). Stored views may still
1362+
// carry the legacy bare `show*` flags, so each side is run through
1363+
// `normalizeListViewSchema` (the single fold) and merged, view over
1364+
// list. This relay never names a legacy key itself.
1365+
userActions: {
1366+
...(normalizeListViewSchema(listSchema ?? {}) as { userActions?: object }).userActions,
1367+
...(normalizeListViewSchema(viewDef ?? {}) as { userActions?: object }).userActions,
1368+
},
13691369
allowExport: viewDef.allowExport ?? listSchema.allowExport,
13701370
exportOptions: viewDef.allowExport === false ? undefined : (viewDef.exportOptions ?? listSchema.exportOptions),
13711371
striped: viewDef.striped ?? listSchema.striped,
@@ -1379,7 +1379,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
13791379
collapseAllByDefault: viewDef.collapseAllByDefault ?? listSchema.collapseAllByDefault,
13801380
fieldTextColor: viewDef.fieldTextColor ?? listSchema.fieldTextColor,
13811381
prefixField: viewDef.prefixField ?? listSchema.prefixField,
1382-
showDescription: viewDef.showDescription ?? listSchema.showDescription,
13831382
// ViewData source override (spec `data` key): a view authored with
13841383
// `data: {provider:'api', read, write}` must survive this explicit
13851384
// picklist, or ObjectGantt falls back to provider:'object'.

packages/core/src/utils/__tests__/normalize-list-view.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,76 @@ describe('normalizeListViewSchema (#2890)', () => {
134134
});
135135
});
136136

137+
describe('show* → userActions', () => {
138+
it('folds every legacy toolbar flag onto its userActions key', () => {
139+
const out = normalizeListViewSchema({
140+
viewType: 'grid',
141+
showSearch: false,
142+
showSort: false,
143+
showFilters: false,
144+
showDensity: false,
145+
showGroup: false,
146+
showHideFields: true,
147+
showColor: true,
148+
}) as Record<string, unknown>;
149+
expect(out.userActions).toEqual({
150+
search: false,
151+
sort: false,
152+
filter: false,
153+
rowHeight: false,
154+
group: false,
155+
hideFields: true,
156+
rowColor: true,
157+
});
158+
for (const flag of ['showSearch', 'showSort', 'showFilters', 'showDensity', 'showGroup', 'showHideFields', 'showColor']) {
159+
expect(flag in out).toBe(false);
160+
}
161+
});
162+
163+
it('merges into an existing userActions instead of replacing it', () => {
164+
const out = normalizeListViewSchema({
165+
viewType: 'grid',
166+
userActions: { search: false, editInline: true },
167+
showGroup: false,
168+
}) as Record<string, unknown>;
169+
expect(out.userActions).toEqual({ search: false, editInline: true, group: false });
170+
});
171+
172+
it('lets the canonical key win per-flag when both are present', () => {
173+
const out = normalizeListViewSchema({
174+
viewType: 'grid',
175+
userActions: { search: true },
176+
showSearch: false,
177+
showSort: false,
178+
}) as Record<string, unknown>;
179+
// `search` stays canonical-true; `sort` folds in from the legacy flag.
180+
expect(out.userActions).toEqual({ search: true, sort: false });
181+
});
182+
183+
it('applies no defaults — an absent flag stays absent', () => {
184+
// The defaults are per-toggle and live in the renderer; baking them in
185+
// here would turn "unset" into "explicitly on" and defeat the merge above.
186+
const out = normalizeListViewSchema({ viewType: 'grid', showGroup: false }) as Record<string, unknown>;
187+
expect(out.userActions).toEqual({ group: false });
188+
});
189+
190+
it('ignores a non-boolean flag', () => {
191+
const out = normalizeListViewSchema({ viewType: 'grid', showSearch: 'yes' }) as Record<string, unknown>;
192+
expect(out.userActions).toBeUndefined();
193+
expect(out.showSearch).toBe('yes');
194+
});
195+
196+
it('folds `showDescription` into `appearance`', () => {
197+
const out = normalizeListViewSchema({
198+
viewType: 'grid',
199+
appearance: { allowedVisualizations: ['grid'] },
200+
showDescription: false,
201+
}) as Record<string, unknown>;
202+
expect(out.appearance).toEqual({ allowedVisualizations: ['grid'], showDescription: false });
203+
expect('showDescription' in out).toBe(false);
204+
});
205+
});
206+
137207
describe('viewType defaulting', () => {
138208
it('defaults a missing view kind to the renderable `grid`', () => {
139209
expect(normalizeListViewSchema({ type: 'list-view' })).toEqual({ type: 'list-view', viewType: 'grid' });

packages/core/src/utils/normalize-list-view.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,28 @@ export function rowHeightToDensityMode(rowHeight: unknown): DensityMode {
5353
return 'comfortable';
5454
}
5555

56+
const isRecord = (v: unknown): v is Record<string, unknown> =>
57+
!!v && typeof v === 'object' && !Array.isArray(v);
58+
59+
/**
60+
* Legacy toolbar-visibility flags → their `userActions` key (#2890 scope A
61+
* step 3). The spec documents `userActions` as "which interactive actions are
62+
* available to users in the view toolbar", and already carries `rowHeight` —
63+
* objectui's `showDensity` under its spec name. `group` / `hideFields` /
64+
* `rowColor` are the same kind of toggle and are named after the config key
65+
* they gate (`grouping`, `hiddenFields`, `rowColor`), pending promotion into
66+
* `UserActionsConfigSchema` upstream.
67+
*/
68+
const SHOW_FLAG_TO_USER_ACTION: Record<string, string> = {
69+
showSearch: 'search',
70+
showSort: 'sort',
71+
showFilters: 'filter',
72+
showDensity: 'rowHeight',
73+
showGroup: 'group',
74+
showHideFields: 'hideFields',
75+
showColor: 'rowColor',
76+
};
77+
5678
/**
5779
* ObjectUI's `list-view` node historically used a different vocabulary from
5880
* `@objectstack/spec` for the same concepts (`fields` where the spec says
@@ -87,6 +109,13 @@ export function rowHeightToDensityMode(rowHeight: unknown): DensityMode {
87109
* Currently folded:
88110
* - `fields` → `columns` (#2890 scope A step 1)
89111
* - `densityMode` → `rowHeight` (#2890 scope A step 2)
112+
* - the `show*` toolbar flags → `userActions` (#2890 scope A step 3), and
113+
* `showDescription` → `appearance.showDescription`. The canonical key wins
114+
* per-flag, so a view may carry `userActions` for some toggles and a legacy
115+
* flag for others. NOTE the fold does not apply any default: an absent flag
116+
* stays absent, because the defaults are per-toggle (search/sort/filter/
117+
* rowHeight/group default ON, hideFields/rowColor default OFF) and belong to
118+
* the renderer, not to the vocabulary bridge.
90119
* - `filters` → `filter` (#2890 scope A step 4). A key rename only: BOTH keys
91120
* carry an ObjectQL FilterNode array (`[['stage','=','won']]`) everywhere in
92121
* objectui — every consumer passes the value straight to `$filter`. The spec
@@ -109,9 +138,16 @@ export function normalizeListViewSchema<T>(schema: T): T {
109138
const foldRowHeight = typeof legacyDensity === 'string' && legacyDensity in DENSITY_MODE_TO_ROW_HEIGHT;
110139
const legacyFilters = s.filters;
111140
const foldFilter = Array.isArray(legacyFilters);
141+
const legacyFlags = Object.keys(SHOW_FLAG_TO_USER_ACTION).filter((k) => typeof s[k] === 'boolean');
142+
const foldDescription = typeof s.showDescription === 'boolean';
112143
const viewType = s.viewType;
113144
const defaultViewKind = !viewType || viewType === 'list';
114-
if (!foldColumns && !foldRowHeight && !foldFilter && !defaultViewKind) return schema;
145+
if (
146+
!foldColumns && !foldRowHeight && !foldFilter && !legacyFlags.length &&
147+
!foldDescription && !defaultViewKind
148+
) {
149+
return schema;
150+
}
115151

116152
const next: Record<string, unknown> = { ...s };
117153
if (foldColumns) {
@@ -128,6 +164,25 @@ export function normalizeListViewSchema<T>(schema: T): T {
128164
if (!Array.isArray(next.filter)) next.filter = legacyFilters;
129165
delete next.filters;
130166
}
167+
if (legacyFlags.length) {
168+
const ua: Record<string, unknown> = { ...(isRecord(next.userActions) ? next.userActions : {}) };
169+
for (const flag of legacyFlags) {
170+
const key = SHOW_FLAG_TO_USER_ACTION[flag];
171+
if (typeof ua[key] !== 'boolean') ua[key] = s[flag];
172+
delete next[flag];
173+
}
174+
next.userActions = ua;
175+
}
176+
if (foldDescription) {
177+
const appearance: Record<string, unknown> = {
178+
...(isRecord(next.appearance) ? next.appearance : {}),
179+
};
180+
if (typeof appearance.showDescription !== 'boolean') {
181+
appearance.showDescription = s.showDescription;
182+
}
183+
delete next.showDescription;
184+
next.appearance = appearance;
185+
}
131186
if (defaultViewKind) next.viewType = 'grid';
132187
return next as T;
133188
}

packages/plugin-list/src/ListView.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -369,26 +369,32 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
369369

370370
// Resolve toolbar visibility flags: userActions overrides showX flags
371371
const toolbarFlags = React.useMemo(() => {
372-
const ua = schema.userActions;
372+
// Every toolbar toggle reads from `userActions` (#2890). The legacy bare
373+
// `show*` flags are folded into it by `normalizeListViewSchema` above, so
374+
// there is no dual-read here — one vocabulary, one place.
375+
//
376+
// The DEFAULTS are per-toggle and deliberately asymmetric, matching what
377+
// these flags have always done: the four view-shaping affordances plus
378+
// grouping are on unless turned off; column visibility and row coloring are
379+
// off unless turned on. (`hideFields`/`rowColor` default OFF is objectui's
380+
// historical behavior, kept deliberately — flipping it would grow two
381+
// buttons on every existing view.)
382+
const ua = schema.userActions as Record<string, boolean | undefined> | undefined;
373383
const addRecordEnabled = schema.addRecord?.enabled === true && ua?.addRecordForm !== false;
374-
// `refresh` is spec-canonical (`userActions.refresh`, @objectstack/spec). The
375-
// installed spec type may predate the field, so read it defensively. Visible by
376-
// default (opt-out via `userActions.refresh: false`), like the other toggles.
377-
const uaRefresh = (ua as { refresh?: boolean } | undefined)?.refresh;
378384
return {
379-
showSearch: ua?.search !== undefined ? ua.search : schema.showSearch !== false,
380-
showSort: ua?.sort !== undefined ? ua.sort : schema.showSort !== false,
381-
showFilters: ua?.filter !== undefined ? ua.filter : schema.showFilters !== false,
382-
showRefresh: uaRefresh !== undefined ? uaRefresh : true,
383-
showDensity: ua?.rowHeight !== undefined ? ua.rowHeight : schema.showDensity !== false,
384-
showHideFields: schema.showHideFields === true,
385-
showGroup: schema.showGroup !== false,
386-
showColor: schema.showColor === true,
385+
showSearch: ua?.search !== false,
386+
showSort: ua?.sort !== false,
387+
showFilters: ua?.filter !== false,
388+
showRefresh: ua?.refresh !== false,
389+
showDensity: ua?.rowHeight !== false,
390+
showGroup: ua?.group !== false,
391+
showHideFields: ua?.hideFields === true,
392+
showColor: ua?.rowColor === true,
387393
compactToolbar: schema.compactToolbar === true,
388394
showAddRecord: addRecordEnabled,
389395
addRecordPosition: (schema.addRecord?.position === 'bottom' ? 'bottom' : 'top') as 'top' | 'bottom',
390396
};
391-
}, [schema.userActions, schema.showSearch, schema.showSort, schema.showFilters, schema.showDensity, schema.showHideFields, schema.showGroup, schema.showColor, schema.compactToolbar, schema.addRecord, schema.userActions?.addRecordForm]);
397+
}, [schema.userActions, schema.compactToolbar, schema.addRecord]);
392398

393399
const [currentView, setCurrentView] = React.useState<ViewType>(
394400
(schema.viewType as ViewType)

0 commit comments

Comments
 (0)