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
52 changes: 52 additions & 0 deletions .changeset/listview-useractions-vocabulary.md
Original file line number Diff line number Diff line change
@@ -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`.
29 changes: 19 additions & 10 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 9 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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,
Expand All @@ -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'.
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/utils/__tests__/normalize-list-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// `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<string, unknown>;
expect(out.userActions).toEqual({ group: false });
});

it('ignores a non-boolean flag', () => {
const out = normalizeListViewSchema({ viewType: 'grid', showSearch: 'yes' }) as Record<string, unknown>;
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<string, unknown>;
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' });
Expand Down
57 changes: 56 additions & 1 deletion packages/core/src/utils/normalize-list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ export function rowHeightToDensityMode(rowHeight: unknown): DensityMode {
return 'comfortable';
}

const isRecord = (v: unknown): v is Record<string, unknown> =>
!!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<string, string> = {
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
Expand Down Expand Up @@ -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
Expand All @@ -109,9 +138,16 @@ export function normalizeListViewSchema<T>(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<string, unknown> = { ...s };
if (foldColumns) {
Expand All @@ -128,6 +164,25 @@ export function normalizeListViewSchema<T>(schema: T): T {
if (!Array.isArray(next.filter)) next.filter = legacyFilters;
delete next.filters;
}
if (legacyFlags.length) {
const ua: Record<string, unknown> = { ...(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<string, unknown> = {
...(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;
}
34 changes: 20 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,26 +369,32 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({

// 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<string, boolean | undefined> | 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<ViewType>(
(schema.viewType as ViewType)
Expand Down
Loading
Loading