From 76147d3aaac8f3cc8103161156fc027907ccbec2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:37:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(views):=20the=20list=20toolbar=20speaks=20?= =?UTF-8?q?one=20vocabulary=20=E2=80=94=20`userActions`=20(#2890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude --- .changeset/listview-useractions-vocabulary.md | 52 ++++++++++++++ .../app-shell/src/views/InterfaceListPage.tsx | 29 +++++--- .../app-shell/src/views/ObjectDataPage.tsx | 17 +++-- packages/app-shell/src/views/ObjectView.tsx | 19 +++-- .../__tests__/normalize-list-view.test.ts | 70 +++++++++++++++++++ .../core/src/utils/normalize-list-view.ts | 57 ++++++++++++++- packages/plugin-list/src/ListView.tsx | 34 +++++---- .../src/__tests__/ListView.test.tsx | 57 +++++++++++++++ packages/plugin-view/src/ObjectView.tsx | 19 ++--- .../src/__tests__/ObjectView.test.tsx | 12 ++-- packages/types/src/zod/objectql.zod.ts | 29 ++++++++ 11 files changed, 338 insertions(+), 57 deletions(-) create mode 100644 .changeset/listview-useractions-vocabulary.md diff --git a/.changeset/listview-useractions-vocabulary.md b/.changeset/listview-useractions-vocabulary.md new file mode 100644 index 0000000000..ff27806b5d --- /dev/null +++ b/.changeset/listview-useractions-vocabulary.md @@ -0,0 +1,52 @@ +--- +"@object-ui/core": minor +"@object-ui/types": minor +"@object-ui/plugin-list": minor +"@object-ui/plugin-view": minor +"@object-ui/app-shell": minor +--- + +feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890 scope A step 3) + +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. + +| legacy | canonical | +| :--- | :--- | +| `showSearch` / `showSort` / `showFilters` / `showDensity` | `userActions.search` / `.sort` / `.filter` / `.rowHeight` | +| `showGroup` / `showHideFields` / `showColor` | `userActions.group` / `.hideFields` / `.rowColor` | +| `showDescription` | `appearance.showDescription` | + +**The last three are new keys, and they close a capability hole rather than just +renaming one.** `@objectstack/spec`'s `UserActionsConfigSchema` documents itself +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. + +The consequence was visible in the product. With no `userActions` key to read, +the two list surfaces **hardcoded opposite policies**: `InterfaceListPage` (the +author-curated interface page) 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 surfaces now express their policy as `userActions` defaults, which +an author can override. + +Until the keys land in `@objectstack/spec`, `@object-ui/types` carries them as a +documented `.extend()` on `UserActionsConfigSchema` (the same shape +`ListColumnSchema` uses while waiting on objectstack#3761); it collapses into a +plain re-export once they do. Note 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 are unchanged and deliberately asymmetric, matching what these flags +have always done: `search` / `sort` / `filter` / `rowHeight` / `group` are on +unless turned off; `hideFields` / `rowColor` are off unless turned on. Making +them uniform would grow two buttons on every existing view, so it is left as its +own product decision rather than smuggled into a vocabulary migration. + +Also drops a dead relay in app-shell's `ObjectView`, which forwarded +`showDescription` onto the node although `ListView` has only ever read +`appearance.showDescription`. diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index 36b181745f..bef685dc83 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -390,16 +390,25 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit // panel's "Add Record" config silently did nothing at runtime. addRecord: cfg.addRecord, - // userActions toggles → toolbar flags. Interface mode is closed by - // default: the advanced filter builder and view-management tools are - // only present when the author opted in. - showSearch: userActions.search !== false, - showSort: userActions.sort !== false, - showFilters: userActions.filter === true, - showDensity: userActions.rowHeight === true, - showHideFields: false, - showGroup: false, - showColor: false, + // userActions passes straight through to the toolbar (#2890) — this used + // to unpack it into bare `show*` flags, which is why the three toggles + // with no `userActions` key (group / hideFields / rowColor) were HARDCODED + // off here and hardcoded on in ObjectDataPage: two surfaces, two opposite + // policies, neither author-controllable. They are author-controllable now. + // + // Interface mode stays closed BY DEFAULT — the advanced filter builder, + // density and the view-management tools are present only when the author + // opted in — but "closed by default" is expressed as a default here, not + // as an unreachable constant. + userActions: { + search: userActions.search !== false, + sort: userActions.sort !== false, + filter: userActions.filter === true, + rowHeight: userActions.rowHeight === true, + group: userActions.group === true, + hideFields: userActions.hideFields === true, + rowColor: userActions.rowColor === true, + }, allowExport: false, // Inline record editing is a page-authored property: a list block opts in // via `userActions.editInline` (default off). When on, clicking a cell diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index ff7926f096..2e891f7563 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -226,13 +226,16 @@ export function ObjectDataPage({ dataSource, objects }: any) { appearance: { allowedVisualizations }, showViewSwitcher: allowedVisualizations.length > 1, // Full list capability — this surface trades the saved-view anchor for - // the complete toolbar, NOT for a reduced one. - showSearch: true, - showSort: true, - showFilters: true, - showGroup: true, - showHideFields: true, - showDensity: true, + // the complete toolbar, NOT for a reduced one. (#2890: expressed as + // `userActions`, the one vocabulary, instead of bare `show*` flags.) + userActions: { + search: true, + sort: true, + filter: true, + rowHeight: true, + group: true, + hideFields: true, + }, showRecordCount: true, // Deliberately NO onSortChange/onFilterChange persistence hooks: this // surface never writes back to any saved view (#2251). diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 45df2c3ef5..18897839fd 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -11,7 +11,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; -import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, type FilterTokenScope } from '@object-ui/core'; +import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core'; import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState'; import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage'; const ObjectChart = lazy(() => @@ -1358,14 +1358,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // whitelist with capability-resolvable types. showViewSwitcher: ((viewDef.appearance ?? listSchema.appearance)?.allowedVisualizations?.length ?? 0) > 1, - // Propagate toolbar/display flags for all view types - showSearch: viewDef.showSearch ?? listSchema.showSearch, - showSort: viewDef.showSort ?? listSchema.showSort, - showFilters: viewDef.showFilters ?? listSchema.showFilters, - showHideFields: viewDef.showHideFields ?? listSchema.showHideFields, - showGroup: viewDef.showGroup ?? listSchema.showGroup, - showColor: viewDef.showColor ?? listSchema.showColor, - showDensity: viewDef.showDensity ?? listSchema.showDensity, + // Toolbar policy — one vocabulary (#2890). Stored views may still + // carry the legacy bare `show*` flags, so each side is run through + // `normalizeListViewSchema` (the single fold) and merged, view over + // list. This relay never names a legacy key itself. + userActions: { + ...(normalizeListViewSchema(listSchema ?? {}) as { userActions?: object }).userActions, + ...(normalizeListViewSchema(viewDef ?? {}) as { userActions?: object }).userActions, + }, allowExport: viewDef.allowExport ?? listSchema.allowExport, exportOptions: viewDef.allowExport === false ? undefined : (viewDef.exportOptions ?? listSchema.exportOptions), striped: viewDef.striped ?? listSchema.striped, @@ -1379,7 +1379,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an collapseAllByDefault: viewDef.collapseAllByDefault ?? listSchema.collapseAllByDefault, fieldTextColor: viewDef.fieldTextColor ?? listSchema.fieldTextColor, prefixField: viewDef.prefixField ?? listSchema.prefixField, - showDescription: viewDef.showDescription ?? listSchema.showDescription, // ViewData source override (spec `data` key): a view authored with // `data: {provider:'api', read, write}` must survive this explicit // picklist, or ObjectGantt falls back to provider:'object'. diff --git a/packages/core/src/utils/__tests__/normalize-list-view.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.test.ts index 0cbbc4ffcb..02994beeca 100644 --- a/packages/core/src/utils/__tests__/normalize-list-view.test.ts +++ b/packages/core/src/utils/__tests__/normalize-list-view.test.ts @@ -134,6 +134,76 @@ describe('normalizeListViewSchema (#2890)', () => { }); }); + describe('show* → userActions', () => { + it('folds every legacy toolbar flag onto its userActions key', () => { + const out = normalizeListViewSchema({ + viewType: 'grid', + showSearch: false, + showSort: false, + showFilters: false, + showDensity: false, + showGroup: false, + showHideFields: true, + showColor: true, + }) as Record; + expect(out.userActions).toEqual({ + search: false, + sort: false, + filter: false, + rowHeight: false, + group: false, + hideFields: true, + rowColor: true, + }); + for (const flag of ['showSearch', 'showSort', 'showFilters', 'showDensity', 'showGroup', 'showHideFields', 'showColor']) { + expect(flag in out).toBe(false); + } + }); + + it('merges into an existing userActions instead of replacing it', () => { + const out = normalizeListViewSchema({ + viewType: 'grid', + userActions: { search: false, editInline: true }, + showGroup: false, + }) as Record; + expect(out.userActions).toEqual({ search: false, editInline: true, group: false }); + }); + + it('lets the canonical key win per-flag when both are present', () => { + const out = normalizeListViewSchema({ + viewType: 'grid', + userActions: { search: true }, + showSearch: false, + showSort: false, + }) as Record; + // `search` stays canonical-true; `sort` folds in from the legacy flag. + expect(out.userActions).toEqual({ search: true, sort: false }); + }); + + it('applies no defaults — an absent flag stays absent', () => { + // The defaults are per-toggle and live in the renderer; baking them in + // here would turn "unset" into "explicitly on" and defeat the merge above. + const out = normalizeListViewSchema({ viewType: 'grid', showGroup: false }) as Record; + expect(out.userActions).toEqual({ group: false }); + }); + + it('ignores a non-boolean flag', () => { + const out = normalizeListViewSchema({ viewType: 'grid', showSearch: 'yes' }) as Record; + expect(out.userActions).toBeUndefined(); + expect(out.showSearch).toBe('yes'); + }); + + it('folds `showDescription` into `appearance`', () => { + const out = normalizeListViewSchema({ + viewType: 'grid', + appearance: { allowedVisualizations: ['grid'] }, + showDescription: false, + }) as Record; + expect(out.appearance).toEqual({ allowedVisualizations: ['grid'], showDescription: false }); + expect('showDescription' in out).toBe(false); + }); + }); + describe('viewType defaulting', () => { it('defaults a missing view kind to the renderable `grid`', () => { expect(normalizeListViewSchema({ type: 'list-view' })).toEqual({ type: 'list-view', viewType: 'grid' }); diff --git a/packages/core/src/utils/normalize-list-view.ts b/packages/core/src/utils/normalize-list-view.ts index e45ffa3582..3536e85c7b 100644 --- a/packages/core/src/utils/normalize-list-view.ts +++ b/packages/core/src/utils/normalize-list-view.ts @@ -53,6 +53,28 @@ export function rowHeightToDensityMode(rowHeight: unknown): DensityMode { return 'comfortable'; } +const isRecord = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); + +/** + * Legacy toolbar-visibility flags → their `userActions` key (#2890 scope A + * step 3). The spec documents `userActions` as "which interactive actions are + * available to users in the view toolbar", and already carries `rowHeight` — + * objectui's `showDensity` under its spec name. `group` / `hideFields` / + * `rowColor` are the same kind of toggle and are named after the config key + * they gate (`grouping`, `hiddenFields`, `rowColor`), pending promotion into + * `UserActionsConfigSchema` upstream. + */ +const SHOW_FLAG_TO_USER_ACTION: Record = { + showSearch: 'search', + showSort: 'sort', + showFilters: 'filter', + showDensity: 'rowHeight', + showGroup: 'group', + showHideFields: 'hideFields', + showColor: 'rowColor', +}; + /** * ObjectUI's `list-view` node historically used a different vocabulary from * `@objectstack/spec` for the same concepts (`fields` where the spec says @@ -87,6 +109,13 @@ export function rowHeightToDensityMode(rowHeight: unknown): DensityMode { * Currently folded: * - `fields` → `columns` (#2890 scope A step 1) * - `densityMode` → `rowHeight` (#2890 scope A step 2) + * - the `show*` toolbar flags → `userActions` (#2890 scope A step 3), and + * `showDescription` → `appearance.showDescription`. The canonical key wins + * per-flag, so a view may carry `userActions` for some toggles and a legacy + * flag for others. NOTE the fold does not apply any default: an absent flag + * stays absent, because the defaults are per-toggle (search/sort/filter/ + * rowHeight/group default ON, hideFields/rowColor default OFF) and belong to + * the renderer, not to the vocabulary bridge. * - `filters` → `filter` (#2890 scope A step 4). A key rename only: BOTH keys * carry an ObjectQL FilterNode array (`[['stage','=','won']]`) everywhere in * objectui — every consumer passes the value straight to `$filter`. The spec @@ -109,9 +138,16 @@ export function normalizeListViewSchema(schema: T): T { const foldRowHeight = typeof legacyDensity === 'string' && legacyDensity in DENSITY_MODE_TO_ROW_HEIGHT; const legacyFilters = s.filters; const foldFilter = Array.isArray(legacyFilters); + const legacyFlags = Object.keys(SHOW_FLAG_TO_USER_ACTION).filter((k) => typeof s[k] === 'boolean'); + const foldDescription = typeof s.showDescription === 'boolean'; const viewType = s.viewType; const defaultViewKind = !viewType || viewType === 'list'; - if (!foldColumns && !foldRowHeight && !foldFilter && !defaultViewKind) return schema; + if ( + !foldColumns && !foldRowHeight && !foldFilter && !legacyFlags.length && + !foldDescription && !defaultViewKind + ) { + return schema; + } const next: Record = { ...s }; if (foldColumns) { @@ -128,6 +164,25 @@ export function normalizeListViewSchema(schema: T): T { if (!Array.isArray(next.filter)) next.filter = legacyFilters; delete next.filters; } + if (legacyFlags.length) { + const ua: Record = { ...(isRecord(next.userActions) ? next.userActions : {}) }; + for (const flag of legacyFlags) { + const key = SHOW_FLAG_TO_USER_ACTION[flag]; + if (typeof ua[key] !== 'boolean') ua[key] = s[flag]; + delete next[flag]; + } + next.userActions = ua; + } + if (foldDescription) { + const appearance: Record = { + ...(isRecord(next.appearance) ? next.appearance : {}), + }; + if (typeof appearance.showDescription !== 'boolean') { + appearance.showDescription = s.showDescription; + } + delete next.showDescription; + next.appearance = appearance; + } if (defaultViewKind) next.viewType = 'grid'; return next as T; } diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index bdac2681d7..08ad75060f 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -369,26 +369,32 @@ export const ListView = React.forwardRef(({ // Resolve toolbar visibility flags: userActions overrides showX flags const toolbarFlags = React.useMemo(() => { - const ua = schema.userActions; + // Every toolbar toggle reads from `userActions` (#2890). The legacy bare + // `show*` flags are folded into it by `normalizeListViewSchema` above, so + // there is no dual-read here — one vocabulary, one place. + // + // The DEFAULTS are per-toggle and deliberately asymmetric, matching what + // these flags have always done: the four view-shaping affordances plus + // grouping are on unless turned off; column visibility and row coloring are + // off unless turned on. (`hideFields`/`rowColor` default OFF is objectui's + // historical behavior, kept deliberately — flipping it would grow two + // buttons on every existing view.) + const ua = schema.userActions as Record | undefined; const addRecordEnabled = schema.addRecord?.enabled === true && ua?.addRecordForm !== false; - // `refresh` is spec-canonical (`userActions.refresh`, @objectstack/spec). The - // installed spec type may predate the field, so read it defensively. Visible by - // default (opt-out via `userActions.refresh: false`), like the other toggles. - const uaRefresh = (ua as { refresh?: boolean } | undefined)?.refresh; return { - showSearch: ua?.search !== undefined ? ua.search : schema.showSearch !== false, - showSort: ua?.sort !== undefined ? ua.sort : schema.showSort !== false, - showFilters: ua?.filter !== undefined ? ua.filter : schema.showFilters !== false, - showRefresh: uaRefresh !== undefined ? uaRefresh : true, - showDensity: ua?.rowHeight !== undefined ? ua.rowHeight : schema.showDensity !== false, - showHideFields: schema.showHideFields === true, - showGroup: schema.showGroup !== false, - showColor: schema.showColor === true, + showSearch: ua?.search !== false, + showSort: ua?.sort !== false, + showFilters: ua?.filter !== false, + showRefresh: ua?.refresh !== false, + showDensity: ua?.rowHeight !== false, + showGroup: ua?.group !== false, + showHideFields: ua?.hideFields === true, + showColor: ua?.rowColor === true, compactToolbar: schema.compactToolbar === true, showAddRecord: addRecordEnabled, addRecordPosition: (schema.addRecord?.position === 'bottom' ? 'bottom' : 'top') as 'top' | 'bottom', }; - }, [schema.userActions, schema.showSearch, schema.showSort, schema.showFilters, schema.showDensity, schema.showHideFields, schema.showGroup, schema.showColor, schema.compactToolbar, schema.addRecord, schema.userActions?.addRecordForm]); + }, [schema.userActions, schema.compactToolbar, schema.addRecord]); const [currentView, setCurrentView] = React.useState( (schema.viewType as ViewType) diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index 4ecf1ea44a..cfae9d48e5 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -1627,6 +1627,63 @@ describe('ListView', () => { expect(screen.queryByTitle(/density/i)).not.toBeInTheDocument(); }); + // #2890 step 3: the three toggles the spec's UserActionsConfigSchema does + // not model yet. Before this they were unreachable from `userActions` — + // hardcoded off in InterfaceListPage and on in ObjectDataPage. + it('should show Hide fields when userActions.hideFields is true', () => { + const schema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + columns: ['name', 'email'], + userActions: { hideFields: true }, + } as unknown as ListViewSchema; + + renderWithProvider(); + expect(screen.getByText('Hide fields')).toBeInTheDocument(); + }); + + it('should hide Group when userActions.group is false', () => { + const schema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + columns: ['name', 'email'], + userActions: { group: false }, + } as unknown as ListViewSchema; + + renderWithProvider(); + expect(screen.queryByText('Group')).not.toBeInTheDocument(); + }); + + it('should keep the legacy show* flags working — stored views carry them', () => { + // Folded by normalizeListViewSchema, so the renderer sees only userActions. + const schema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + columns: ['name', 'email'], + showHideFields: true, + } as unknown as ListViewSchema; + + renderWithProvider(); + expect(screen.getByText('Hide fields')).toBeInTheDocument(); + }); + + it('should let userActions win over a conflicting legacy flag', () => { + const schema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + columns: ['name', 'email'], + userActions: { hideFields: false }, + showHideFields: true, + } as unknown as ListViewSchema; + + renderWithProvider(); + expect(screen.queryByText('Hide fields')).not.toBeInTheDocument(); + }); + it('should show toolbar buttons when userActions are true', () => { const schema: ListViewSchema = { type: 'list-view', diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index cf9ef26121..901c51f4c2 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -58,7 +58,7 @@ import { } from '@object-ui/components'; import { Plus } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; -import { buildExpandFields } from '@object-ui/core'; +import { buildExpandFields, normalizeListViewSchema } from '@object-ui/core'; import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react'; import { ViewSwitcher } from './ViewSwitcher'; import { deriveRecordSurface } from './recordSurface'; @@ -1016,14 +1016,15 @@ export const ObjectView: React.FC = ({ groupBy2: activeView?.groupBy2, grouping: activeView?.grouping, options: currentNamedViewConfig?.options || activeView, - // Propagate toolbar toggle flags - showSearch: activeView?.showSearch ?? schema.showSearch, - showFilters: activeView?.showFilters ?? schema.showFilters, - showSort: activeView?.showSort ?? schema.showSort, - showHideFields: activeView?.showHideFields ?? (schema as any).showHideFields, - showGroup: activeView?.showGroup ?? (schema as any).showGroup, - showColor: activeView?.showColor ?? (schema as any).showColor, - showDensity: activeView?.showDensity ?? (schema as any).showDensity, + // Toolbar policy — one vocabulary (#2890). The host node and the + // active view may still carry the legacy bare `show*` flags, so both + // go through `normalizeListViewSchema` (the single fold) and merge, + // view over host. `densityMode`/`rowHeight` above take the same route + // once step 2's fold runs at the ListView boundary. + userActions: { + ...(normalizeListViewSchema(schema ?? {}) as { userActions?: object }).userActions, + ...(normalizeListViewSchema(activeView ?? {}) as { userActions?: object }).userActions, + }, compactToolbar: activeView?.compactToolbar ?? (schema as any).compactToolbar, allowExport: activeView?.allowExport ?? (schema as any).allowExport, // Propagate display properties diff --git a/packages/plugin-view/src/__tests__/ObjectView.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.test.tsx index 52697d182c..0534f6a6de 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.test.tsx @@ -640,9 +640,9 @@ describe('ObjectView', () => { expect(renderListViewSpy).toHaveBeenCalled(); const callSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema; - expect(callSchema?.showSearch).toBe(false); - expect(callSchema?.showFilters).toBe(false); - expect(callSchema?.showSort).toBe(false); + // #2890: the payload speaks `userActions`, one vocabulary, instead of + // unpacking the view's toggles into bare `show*` flags. + expect(callSchema?.userActions).toMatchObject({ search: false, filter: false, sort: false }); }); it('should propagate showSearch/showFilters/showSort from activeView in renderListView', async () => { @@ -671,9 +671,9 @@ describe('ObjectView', () => { expect(renderListViewSpy).toHaveBeenCalled(); const callSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema; - expect(callSchema?.showSearch).toBe(false); - expect(callSchema?.showFilters).toBe(false); - expect(callSchema?.showSort).toBe(false); + // #2890: the payload speaks `userActions`, one vocabulary, instead of + // unpacking the view's toggles into bare `show*` flags. + expect(callSchema?.userActions).toMatchObject({ search: false, filter: false, sort: false }); }); it('should propagate the ViewData `data` block (api provider) into the renderListView schema', async () => { diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 492252034d..284a5030f5 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -30,6 +30,7 @@ import { ColumnSummarySchema as SpecColumnSummarySchema, SelectionConfigSchema as SpecSelectionConfigSchema, PaginationConfigSchema as SpecPaginationConfigSchema, + UserActionsConfigSchema as SpecUserActionsConfigSchema, } from '@objectstack/spec/ui'; import { BaseSchema } from './base.zod.js'; @@ -307,6 +308,7 @@ const SpecListViewFields = SpecListViewSchema label: true, description: true, userFilters: true, + userActions: true, sharing: true, aria: true, conditionalFormatting: true, @@ -364,6 +366,31 @@ const TimelineConfig = SpecTimelineConfigSchema.partial().extend({ // View-kind enum reused from spec (unwrap its `.default('grid')`) so it cannot drift. const ViewKindEnum = SpecListViewSchema.shape.type.removeDefault(); +/** + * User Actions — the spec's `UserActionsConfigSchema` plus the three toolbar + * affordances it does not model yet (#2890 scope A step 3). + * + * The spec documents this object 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 old + * `showDensity`). 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, so an author cannot express a complete toolbar policy. These + * three are named after the config key they gate, following the precedent + * `rowHeight` set. + * + * This `.extend()` is temporary: it collapses into a plain re-export once the + * keys land upstream. Note `UserActionsConfigSchema` is NOT `.strict()`, so + * before this extension an author writing `userActions: { group: false }` had + * it silently stripped — valid on parse, no effect at render. + */ +export const UserActionsSchema = SpecUserActionsConfigSchema.extend({ + group: z.boolean().optional().describe('Allow users to group records'), + hideFields: z.boolean().optional().describe('Allow users to show/hide columns'), + rowColor: z.boolean().optional().describe('Allow users to color rows by a field value'), +}); + export const ListViewSchema = BaseSchema // Import spec-owned fields by reference: data, filter, sort, searchableFields, // filterableFields, resizable, striped, bordered, compactToolbar, selection, navigation, @@ -427,6 +454,8 @@ export const ListViewSchema = BaseSchema }).optional().describe('Enabled operations'), // ── Local overrides: objectui shapes are intentionally broader than spec (deferred) ── userFilters: UserFiltersSchema.optional().describe('User filters configuration (accepts legacy tab shapes)'), + // Spec `userActions` + the three toolbar toggles it does not model yet (#2890). + userActions: UserActionsSchema.optional().describe('User action toggles for the view toolbar'), sharing: z.object({ visibility: z.enum(['private', 'team', 'organization', 'public']).optional(), enabled: z.boolean().optional(),