ListView plugin for ObjectUI - A unified view component with view type switching, filtering, sorting, and view configuration persistence.
- View Type Switching: Switch between Grid, Kanban, Gallery, Calendar, Timeline, Gantt, Map, Chart and Tree views
- View Persistence: Automatically saves user's view preference
- Integrated Search: Full-text search across records
- Filtering: Advanced filter UI (expandable filter panel)
- Sorting: Sort by any field, toggle ascending/descending
- Flexible Configuration: Configure available view types per object
- Custom Templates: Support for custom view options per view type
The toolbar and cell renderers are tuned for low visual noise on dense tables:
- Unified toolbar row: view tabs (
schema.tabs), user filters and tool buttons share a single bordered row. The previous stacked rows (tabs/description/toolbar) are collapsed into one separator line. - Flat user-filter pills:
userFilters(dropdown mode) render as ghost text + count. Active state is shown viatext-foreground font-mediumrather than a filled / bordered pill. - Quiet active state for tool buttons: filter / group / sort / color /
density / search no longer paint a
bg-primary/10 borderblock when active — they switch totext-foreground font-mediumand rely on the trailing count for emphasis. - Dot-style select/status cells (opt-in): the cell renderer supports
appearance: 'dot'to render● labelinstead of a filled badge for high-density tables. This is opt-in — by default select/status cells render as filled badges in both list and detail views, keeping visual consistency across views. Setappearance: 'dot'on the field (or column) in metadata when you want the lighter style.
pnpm add @object-ui/plugin-listimport { ListView } from '@object-ui/plugin-list';
function ContactsView() {
return (
<ListView
schema={{
type: 'list-view',
objectName: 'contacts',
viewType: 'grid',
columns: ['name', 'email', 'phone', 'company'],
sort: [{ field: 'name', order: 'asc' }],
}}
/>
);
}Group rows in grid/gallery views by one or more fields. Two equivalent shapes are supported on the schema:
Spec-compliant: a structured GroupingConfig (multi-level, with per-field
options).
import { ListView } from '@object-ui/plugin-list';
<ListView
schema={{
type: 'list-view',
objectName: 'tasks',
viewType: 'grid',
columns: ['title', 'status', 'assignee'],
grouping: {
fields: [
{ field: 'status', order: 'asc', collapsed: false },
{ field: 'assignee', order: 'asc', collapsed: true },
],
},
}}
/>Shorthand: a single field name, the shape the visual view-config UI emits. It is
normalized internally into the GroupingConfig above — an alternative to the
block before it, never a second view rendered beside it.
import { ListView } from '@object-ui/plugin-list';
<ListView
schema={{
type: 'list-view',
objectName: 'tasks',
viewType: 'grid',
columns: ['title', 'status'],
groupBy: 'status',
}}
/>When both are present, grouping wins. End users can also add or remove
grouping fields at runtime via the Group toolbar button. Every such edit, in
the toolbar's Group panel or in the compact toolbar's View settings popover
(Clear included), fires onGroupingChange with the spec GroupingConfig, or
with undefined when the grouping is cleared. A changed schema.grouping that
the list re-reads does not fire it: that value came from the host
(objectui#11860).
A grouped grid is grouped on the server: over a data source that answers
the group header query (dataSource.queryGroupHeaders), ListView hands the
grid its own fetch, and the group set, every group count and each group's
rows come from the query (see Grouping is server-side in the
@object-ui/plugin-grid README). A toolbar search is handed to that grid too,
with the view's searchableFields: the grid puts the term on the group header
query and on every group's row query, so only the groups holding matches are
shown and each count is its matching rows (objectui#11021).
Over a data source that declares no queryGroupHeaders, ListView does not
fetch a window and group it — every count would be a page slice, and groups
past the window would be missing. It shows an error naming
queryGroupHeaders in place of the grid instead, fetches no rows, and keeps
the toolbar, so removing the grouping there lifts it (objectui#10881). Rows
handed in whole (data as an array, or { provider: 'value', items }) are
still grouped in the browser, exactly.
Each view type's configuration is a block of that name at the top level of the
schema: kanban, calendar, gallery, timeline, gantt, map, chart,
tree. Each block is @objectstack/spec's own list-view block, and an unknown
key in it is refused by name.
import { ListView } from '@object-ui/plugin-list';
<ListView
schema={{
type: 'list-view',
objectName: 'deals',
viewType: 'kanban',
columns: ['name', 'amount', 'stage', 'close_date'],
kanban: {
groupByField: 'stage',
columns: ['name', 'amount'],
titleField: 'name',
},
calendar: {
startDateField: 'close_date',
titleField: 'name',
},
// A chart binds a semantic-layer dataset and selects its dimensions and
// measures by name (ADR-0021).
chart: {
chartType: 'bar',
dataset: 'deal_pipeline',
dimensions: ['stage'],
values: ['total_amount'],
},
}}
/>A stored view may also carry the same blocks in a legacy options bag
(options.kanban, …), which the platform's view write door judges key by key
with the same block schemas. ListView reads it under the top-level block, which
wins per key. Author the top-level blocks.
import { ListView } from '@object-ui/plugin-list';
<ListView
schema={{
type: 'list-view',
objectName: 'tasks',
columns: ['title', 'status', 'priority'],
}}
onViewChange={(view) => console.log('View changed to:', view)}
onSearchChange={(search) => console.log('Search:', search)}
onSortChange={(sort) => console.log('Sort:', sort)}
onFilterChange={(filters) => console.log('Filters:', filters)}
onGroupingChange={(grouping) => console.log('Grouping:', grouping)}
/>The ListView component accepts a ListViewSchema, exported from
@object-ui/types. That type is derived from the package's zod schema — which
itself derives from @objectstack/spec — so it cannot drift from the protocol.
This page therefore annotates an example against the shipped type instead of
restating it as a second hand-written interface: every key below is re-checked
on every commit, and a shape the type stops accepting fails here rather than
misleading a reader.
import type { ListViewSchema } from '@object-ui/types';
const view: ListViewSchema = {
type: 'list-view',
objectName: 'tasks',
viewType: 'grid',
// 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: ['title', 'status', 'assignee'],
filters: [['status', '=', 'open']],
sort: [{ field: 'title', order: 'asc' }],
// One block per view type, at the top level. A grid has no block of its own:
// its settings are the top-level keys above.
kanban: { groupByField: 'status', columns: ['assignee'], titleField: 'title' },
calendar: { startDateField: 'due_date', titleField: 'title' },
chart: { chartType: 'bar', dataset: 'task_status', dimensions: ['status'], values: ['total_amount'] },
};
// `columns` also accepts ListColumn objects in place of the field-name strings.
const richColumns: ListViewSchema['columns'] = [
{ field: 'title', label: 'Title', width: 200 },
];
// The view-type vocabulary, written as a record keyed by the shipped union so
// this list cannot go stale: a value added to or removed from
// ListViewSchema['viewType'] fails this block.
const viewTypes: Record<NonNullable<ListViewSchema['viewType']>, string> = {
grid: 'Rows and columns',
kanban: 'Cards grouped into columns',
gallery: 'Card grid',
calendar: 'Records on a month / week calendar',
timeline: 'Records bucketed on a date axis',
gantt: 'Bars over a project timeline',
map: 'Records at their geographic coordinates',
chart: 'Aggregated bar / line / pie / area chart',
tree: 'Hierarchical parent-child rows',
};
export { view, richColumns, viewTypes };A list view asks the server for one window of records. When
pagination.pageSize is not declared, what that window is depends on whether
the view pages:
- The grid view pages on the server. The window is one page, and the
grid's pager turns it. Its size is the default
@objectstack/specdeclares forpagination.pageSize; the list reads it from the spec rather than keeping a number of its own. - Every other view does not page — kanban, gallery and the rest, and a grouped grid. The window is one fetch batch of 100 records, and records past it are not reachable; the record-count bar says so when the batch comes back full. It is a fetch size, not a page size, so it does not follow the spec's display default.
- The calendar view fetches the days it shows. With a start date bound
(
calendar.startDateField), the window is the calendar's visible days on that field (and oncalendar.endDateFieldwhen one is bound, so a span that runs into the month is fetched), plus the records with no start date, which the calendar lists as unscheduled. The list walks that window in steps of the same fetch batch of 100 until it is exhausted, so every record of the month is drawn, and stops at the platform's non-grid ceiling of 2,000 records with a note under the calendar naming both numbers. Moving to a month the fetched window does not cover fetches that month. The calendar reports the days it draws through itsonVisibleRangeChangecallback (objectui#12081).
A declared pagination.pageSize sizes the window on every view but the
calendar, as before. With no declared size, switching between the grid view and
another view changes the window, so the list fetches again.
On a metadata page, a list-view component can bind its data through the spec's
per-element data source (PageComponentSchema.dataSource,
ElementDataSourceSchema) instead of spelling objectName and inlining the
view's configuration:
{
"type": "list-view",
"dataSource": { "object": "account", "view": "hot", "limit": 10 }
}view names a saved view of that object — either one embedded in the object
definition (listViews) or one created in the UI (the metadata overlay an
adapter serves from listViews()). Its columns, filter, sort, page size
and view kind are applied to the render, so a page no longer has to keep a second
copy of a view's configuration in sync with the view itself. Both the short key
(hot) and the qualified id (account.hot) resolve.
Precedence. dataSource.* keys are authoritative — the author wrote them on
this placement, and they beat the component's own same-named key. Values that
come from the named view are a baseline: a key written on the component itself
is more specific than the view it points at, so the component's key wins
(an empty columns: [] counts as "not authored"). filter is the exception —
the spec calls dataSource.filter "additional filter criteria", so the
component's filter, the view's filter and the binding's filter all AND together.
A binding can narrow what the view selects, never widen it.
An unresolvable view is an error, not an empty table. If the named view
does not exist, the block renders a configuration error listing the object's
actual views, and issues no query. It deliberately does not fall back to the
object's default view: that would turn a typo into a silently wider answer on a
page that still looks like it works.
The toolbar sort becomes a server $orderby on the flat field name, so the
sort key is whatever that field stores. For a relational field
(lookup / master_detail / user / tree) that is the foreign-key id,
while the column shows the related record's name — the rows would come back in
an order with no visible relation to the column ("sorting is broken", from the
user's side). The server cannot order by the related name without a join, and
objectstack#4256 settled that it will not add one.
So the sort picker withholds relational fields and says so. To sort by a related record's name, denormalize that name onto this object as a stored field, written when the source changes, then sort by that field — the remedy the server's own refusal prescribes, in the same words the sort panel's hint uses.
Not a formula field. A formula value is computed on read, so no driver
materializes a column behind it, and since objectstack#6994 the server answers
a sort that names one with a hard 400 INVALID_SORT (before that it degraded
silently: the rows came back in an arbitrary order under a 200, asc and
desc identical). The picker withholds formula fields for the same reason
(objectui#4243), so there is no formula column here to sort "like any other
text column" — the column you sort is the stored one the denormalization writes.
A field the view's CURRENT sort already uses stays listed under both rules —
relational ones labelled (by ID) — so existing view metadata round-trips
instead of silently losing its sort, and a sort the server would refuse can
still be edited away in the picker that otherwise hides its field.
Column-header sorting inside the grid follows the same two rules, because a
header click is a server $orderby as well whenever the grid is showing one
window of a larger collection (objectui#3106) — not a client-side reorder of
the loaded rows. So on that path a relational or formula column carries no
clickable header either (objectui#3950); offering one would have been the same
illusion through a different control.
Where the sort really does stay in the browser — inline data — both kinds of
header stay live and order by the value the cell shows: the resolved label for
a relational column, the server-hydrated result for a formula one (see
getSortValue in @object-ui/core).
The ListView automatically persists the user's view type preference in localStorage using the key listview-{objectName}-view.
MIT — see LICENSE.