Grid plugin for Object UI - Advanced data grid with sorting, filtering, and pagination.
- Data grid — enterprise-grade grid over one ObjectQL object
- Sorting — per column (
ListColumn.sortable), with an initialsortorder - Filtering & search — a metadata
filteron the query, plus a toolbar search oversearchableFields - Pagination —
pagination: { pageSize, pageSizeOptions } - Row selection —
selection: { type: 'single' | 'multiple' } - Inline editing —
editable, persisted through the host's data source
pnpm add @object-ui/plugin-gridThere is no registration call to make. Importing the package entry once runs the
three ComponentRegistry.register(…) calls in src/index.tsx, and from then on
the schema types below resolve:
// In your app entry point (e.g., App.tsx or main.tsx)
import '@object-ui/plugin-grid';
// The data grid is `object-grid`, and it queries an object — see below for why
// this is not `type: 'grid'`.
const schema = {
type: 'object-grid',
objectName: 'users',
columns: ['name', 'email']
};register(type, component, { namespace }) publishes namespace:type, and — unless
skipFallback is set — the bare type as a back-compat fallback
(packages/core/src/registry/Registry.ts:194, fallback at :226-240). So the
three calls in src/index.tsx claim exactly these keys:
register(…) call |
Namespaced key | Bare fallback |
|---|---|---|
('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' }) — src/index.tsx:202 |
plugin-grid:object-grid |
object-grid |
('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true }) — src/index.tsx:214 |
view:grid |
none — skipFallback: true |
('import-wizard', ImportWizardRenderer, { namespace: 'plugin-grid' }) — src/index.tsx:237 |
plugin-grid:import-wizard |
import-wizard |
Bare grid is deliberately not ours. skipFallback: true on the second call
keeps this plugin from claiming it, because grid belongs to the CSS Grid layout
container in @object-ui/components
(src/renderers/layout/grid.tsx:50), whose schema type is GridSchema in
@object-ui/types (src/layout.ts:202 — columns there is a column count,
not a column list). A schema written as { type: 'grid', columns: [...] } therefore
renders that layout container, not this data grid. Use object-grid, or view:grid
when you want the namespaced spelling.
If you need the data grid under an additional key, register the exported renderer directly — that is what a "manual registration" is here:
import { ObjectGridRenderer } from '@object-ui/plugin-grid';
import { ComponentRegistry } from '@object-ui/core';
ComponentRegistry.register('my-grid', ObjectGridRenderer, {
namespace: 'my-app',
label: 'My Grid',
category: 'plugin',
});The complete public surface — 20 values and 29 types:
import {
ObjectGrid,
ObjectGridRenderer,
VirtualGrid,
SplitPaneGrid,
ImportWizard,
InlineEditing,
FormulaBar,
GroupRow,
RowActionMenu,
BulkActionBar,
formatActionLabel,
inferColumnType,
parseSpreadsheetFile,
parseClipboardTable,
useCellClipboard,
useColumnSummary,
useGradientColor,
useGroupReorder,
useGroupedData,
useRowColor,
} from '@object-ui/plugin-grid';
import type {
ObjectGridComponentProps,
ObjectGridColumnState,
ObjectGridExternalPaginationProps,
ObjectGridProps, // deprecated alias of ObjectGridComponentProps (objectui#4650)
VirtualGridProps,
VirtualGridColumn,
SplitPaneGridProps,
ImportWizardProps,
ImportResult,
InlineEditingProps,
FormulaBarProps,
GroupRowProps,
RowActionMenuProps,
BulkActionBarProps,
GroupEntry,
UseGroupedDataResult,
AggregationType,
AggregationConfig,
AggregationResult,
CellRange,
UseCellClipboardOptions,
UseCellClipboardResult,
GradientStop,
UseGradientColorOptions,
UseGroupReorderOptions,
UseGroupReorderResult,
ColumnSummarySetting,
ColumnSummaryType,
ColumnSummaryResult,
} from '@object-ui/plugin-grid';The schema types are not here — they live in @object-ui/types
(ObjectGridSchema, ListColumn), because the schema is the shared authoring
contract rather than this package's component API.
A grid node is an ObjectGridSchema: one required objectName, and keys drawn
from the list this package declares as its authoring surface
(GRID_QUERY_INPUTS, src/index.tsx:166) — the same list that feeds the designer
panel and the generated sdui-intrinsics.d.ts, so what is authorable here is what
the renderer reads.
import type { ObjectGridSchema } from '@object-ui/types';
const grid: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: ['name', 'email'],
sort: [{ field: 'created', order: 'desc' }],
pagination: { pageSize: 20 },
selection: { type: 'multiple' },
};| Key | Type | Notes |
|---|---|---|
objectName |
string (required) |
The object queried. There is no object. |
columns |
string[] | ListColumn[] |
Field names or column objects — see below. |
label |
I18nLabel |
Table caption and export title. |
filter |
ViewFilterRule[] |
Lowered to $filter. |
sort |
[{ field, order }] |
Initial order; a header click replaces it. |
pagination |
PaginationConfig |
{ pageSize?, pageSizeOptions? } — strict, and its presence is what enables paging. |
searchableFields |
string[] |
A non-empty list is what enables the toolbar search. |
data |
ViewData |
Bypasses the object query — see Inline data. |
selection |
SelectionConfig |
{ type: 'none' | 'single' | 'multiple' }. |
rowActions / bulkActions |
string[] |
Names of actions, not definitions. |
editable / singleClickEdit |
boolean |
Inline editing — see Inline Editing. |
navigation |
NavigationConfig |
What a row click does, { mode: 'page' | 'drawer' | 'modal' | 'split' | 'none', … }. |
operations |
object |
Toggles the built-in CRUD/export/import affordances, e.g. { delete: false }. |
rowHeight, frozenColumns, resizable, reorderableColumns, showColumnTypeIcons, rowColor, conditionalFormatting, grouping, aggregations, exportOptions, className |
The rest of the declared surface. |
There is no sortable, filterable, onRowClick, onSelectionChange,
onCellChange, onRowSave, onBatchSave or object on this schema. The
booleans do not exist at all; the five on* names are component props
(ObjectGridComponentProps), which no metadata document can carry — see
Row callbacks are component props.
A column is either a field name ('name') or a ListColumn object. ListColumn
is declared by @objectstack/spec/ui (ListColumnSchema) and re-exported from
@object-ui/types; it is the type of ObjectGridSchema['columns'], so it is the
same column vocabulary the saved-view metadata uses.
| Key | Type | Meaning |
|---|---|---|
field |
string (required) |
The field this column reads. There is no accessorKey. |
label |
string | Record<string, string> |
Header text, or an inline locale map. There is no header. |
width |
number |
Column width in pixels. |
align |
'left' | 'center' | 'right' |
Cell alignment. |
hidden |
boolean |
Hidden by default, revealable in the column chooser. |
sortable |
boolean |
Allow sorting on this column. |
resizable |
boolean |
Allow dragging this column's width. |
wrap |
boolean |
Wrap long cell text instead of eliding it. |
type |
string |
Override the rendered cell type instead of inferring it from the field. |
pinned |
'left' | 'right' |
Freeze the column to one edge. |
summary |
ColumnSummary | { type; field? } |
Footer aggregation — see Column Summaries. |
prefix |
{ field: string; type?: 'text' | 'badge' } |
Render a second field inline before the value. |
link |
boolean |
Render the value as a link to the record. |
action |
string |
Run a named action when the cell is clicked. |
ListColumnSchema is a strict Zod object, so an unknown key is rejected rather
than ignored — a column is spelled this one way.
import type { ListColumn } from '@object-ui/types';
const columns: ListColumn[] = [
{ field: 'name', label: 'Full Name', width: 200, sortable: true, pinned: 'left', link: true },
{ field: 'stage', type: 'select', prefix: { field: 'health', type: 'badge' }, wrap: true },
{ field: 'amount', type: 'currency', align: 'right', summary: 'sum', resizable: true },
{ field: 'owner_id', label: { en: 'Owner', 'zh-CN': '负责人' }, hidden: true, action: 'reassign' },
];A column can declare a footer aggregation with summary, either as a shorthand
string or as an object that aggregates a different field than the one displayed:
{
"columns": [
{ "field": "name", "summary": "count_filled" },
{ "field": "amount", "type": "currency", "summary": "sum" },
{ "field": "owner", "summary": { "type": "count_unique", "field": "owner_id" } }
]
}The accepted values are ColumnSummarySchema from @objectstack/spec:
summary |
Footer shows | Reads |
|---|---|---|
none |
nothing — the column opts out | — |
count |
number of rows | every row |
count_filled |
rows whose cell is non-empty | raw values |
count_empty |
rows whose cell is empty | raw values |
count_unique |
distinct non-empty values | raw values |
percent_filled |
share of rows that are non-empty | raw values |
percent_empty |
share of rows that are empty | raw values |
sum |
total | numeric values |
avg |
mean | numeric values |
min |
smallest | numeric values |
max |
largest | numeric values |
A cell counts as empty when it is null, undefined, "" or an empty array,
so an unset multi-select or lookup reads as empty rather than as a filled [].
The count and percent families read raw cell values, so they work on text,
select and lookup columns. sum/avg/min/max need numeric values (numeric
strings are parsed) and render nothing when the column has none.
A currency or percent column formats its sum/avg/min/max in that
unit. Counts stay plain cardinalities and percentages carry their own %, so
count_unique on a currency column reads Unique: 3, not $3.00.
The footer row renders only when at least one column resolves to a summary — a
view whose columns are all none (or carry no summary) has no footer.
Every example below is annotated ObjectGridSchema, which is the point: an
un-annotated const schema = { … } type-checks no matter what is written in it,
so a snippet that carries no annotation cannot tell you whether its keys are real.
And note the type — object-grid, never grid. Bare grid renders the CSS Grid
layout container from @object-ui/components
(above), which is how a copied example ends
up leaking columns="[object Object]" into the DOM instead of drawing a table
(objectui#4787).
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: [
{ field: 'name', label: 'Name', width: 200, sortable: true },
{ field: 'email', label: 'Email' },
{ field: 'role', label: 'Role' },
{ field: 'status', label: 'Status', type: 'select' },
],
sort: [{ field: 'name', order: 'asc' }],
pagination: { pageSize: 20 },
};Sorting is declared per column (ListColumn.sortable) — there is no top-level
sortable switch, and none is needed: a column is sortable by default, so the key
is there to turn one off.
A grid normally queries objectName. To render fixed rows instead — demos,
fixtures, tests — give it a ViewData with the value provider. The rows go
under items; a bare array is the deprecated staticData spelling.
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'email', label: 'Email' },
],
data: {
provider: 'value',
items: [
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Active' },
],
},
};A column does not carry a render function. What it can say is which cell type to use and how to decorate the value — the same vocabulary the saved-view metadata uses, so a grid authored by hand and one authored in the designer render alike.
import type { ListColumn } from '@object-ui/types';
const columns: ListColumn[] = [
{ field: 'status', type: 'select' },
{ field: 'amount', type: 'currency', align: 'right', summary: 'sum' },
{ field: 'name', link: true, prefix: { field: 'health', type: 'badge' } },
{ field: 'owner_id', action: 'reassign' },
];link: true renders the value as a link to the record and action: 'reassign'
runs a named action on click — that is the metadata form of the "Actions column"
a render function used to be written for. A genuinely custom cell renderer is
a component-layer concern: VirtualGridColumn.cell on VirtualGrid, a React prop,
not an authoring key.
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: ['name', 'email'],
selection: { type: 'multiple' },
bulkActions: ['delete', 'export'],
};selection.type is the canonical spelling; the boolean selectable is a
deprecated legacy alias, read only when selection is absent. Declaring bulk
actions auto-enables multi-select, so the two keys agree by construction.
To react to a selection in React, pass the onRowSelect component prop —
see Row callbacks are component props.
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: ['name', 'email'],
pagination: { pageSize: 10, pageSizeOptions: [10, 20, 50, 100] },
};PaginationConfig is a strict object of exactly pageSize and
pageSizeOptions — there is no showSizeChanger, and none is needed: the pager
always carries a rows-per-page picker, and pageSizeOptions only replaces the
choices it offers with your own.
The adapter is not a schema key. A schema is a serialisable document; a live
adapter is an object with methods, so it cannot travel in one. The grid reads its
adapter from React context — useSchemaContext() at src/index.tsx:80 — which the
host installs once, above the whole tree:
import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import '@object-ui/plugin-grid';
import type { ObjectGridSchema } from '@object-ui/types';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-auth-token',
});
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'email', label: 'Email' },
{ field: 'created', label: 'Created', type: 'datetime' },
],
filter: [{ field: 'status', operator: 'equals', value: 'active' }],
searchableFields: ['name', 'email'],
pagination: { pageSize: 20 },
};
export const App = () => (
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
);The object comes from objectName; there is no object key. Filtering is the
metadata filter (lowered to $filter) and search is searchableFields (lowered
to $searchFields) — a non-empty list is what puts the search box in the toolbar.
A top-level
dataSourcedoes mean something on a schema node, but it is not this: it is the spec's element binding (PageComponentSchema.dataSource, objectstack#6953) — a descriptor such as{ object: 'users', view: 'my_view' }, resolved byuseElementDataSource(packages/react/src/hooks/useElementDataSource.ts:139) and mapped onto this grid's keys by the gate atsrc/index.tsx:88. Handing that slot a live adapter is rejected on purpose: the predicate refuses any value carrying afindmethod (packages/core/src/data-scope/element-data-source.ts:131), so an adapter written there is silently ignored rather than mistaken for a binding. Pass adapters through the provider above; see the spec for the binding's own surface.
Columns sort by default. sortable is a per-column key, used to turn a column
off; the grid-level sort declares the order the grid opens with.
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
sort: [{ field: 'created', order: 'desc' }],
columns: [
{ field: 'name', label: 'Name' },
{ field: 'email', label: 'Email', sortable: false },
],
};There is no per-column filter key. A grid narrows its query two ways: a filter
baked into the metadata, and a toolbar search over the fields named in
searchableFields.
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
filter: [
{ field: 'status', operator: 'equals', value: 'active' },
{ field: 'created', operator: 'after', value: '2026-01-01' },
],
searchableFields: ['name', 'email'],
columns: ['name', 'email', 'status'],
};rowActions and bulkActions are lists of action names — the actions
themselves live in the object's action set, so the same action behaves identically
wherever it is offered. They are string[], not inline definitions with callbacks.
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: ['name', 'email'],
rowActions: ['view', 'edit', 'delete'],
selection: { type: 'multiple' },
bulkActions: ['delete', 'export'],
};onRowClick, onRowSelect, onCellChange, onRowSave, onBatchSave, onEdit,
onDelete, onBulkDelete and onAddRecord are React props on
ObjectGridComponentProps — they are functions, so no metadata document can hold
them, and writing one into a schema does nothing at all: the grid builds the inner
table's handlers itself and never reads any of these nine off the schema.
The one callback the grid does read off the schema is onNavigate, declared on
ObjectGridSchema for programmatic callers only. It is a function value too, so
it is no more authorable than the nine — it is deliberately absent from the
manifest and the designer panel, and prefer passing it as a prop
(objectui#5234, maintainer ruling of 2026-08-19).
import { ObjectGrid } from '@object-ui/plugin-grid';
import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';
export const Grid = (props: ObjectGridComponentProps) => (
<ObjectGrid
{...props}
onRowClick={(record) => console.log('Row clicked:', record)}
onRowSelect={(rows) => console.log('Selection changed:', rows)}
/>
);Note onRowSelect — the prop that reports a selection change is spelled that way;
there is no onSelectionChange on this component.
The declarative alternative, which is metadata and survives a round trip through
storage, is navigation: { mode: 'page' | 'drawer' | 'modal' | 'split' | 'none' }
decides what a row click does without any host code.
Enable inline cell editing for quick updates:
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: [
{ field: 'id', label: 'ID' },
{ field: 'name', label: 'Name' },
{ field: 'email', label: 'Email' },
{ field: 'status', label: 'Status', type: 'select' },
],
editable: true,
singleClickEdit: false,
};editable is the only switch: it is a grid-level flag, and edits persist through
the host's data source (dataSource.update) with no callback to wire.
Inline Editing Features:
- Double-click to edit: double-click any editable cell to enter edit mode
(
singleClickEdit: trueopens it on the first click instead) - Keyboard shortcuts:
- Press
Enteron a focused cell to start editing - Press
Enterwhile editing to save changes - Press
Escapeto cancel editing
- Press
- Per-field read-only: which cells open is decided by the field definition,
not by a column key — a field marked
readonly, and computed/binary field types (formula, autonumber, file, …), never open an editor (isFieldInlineEditable,src/inline-edit-options.ts:82). There is noeditablekey onListColumn. - Visual feedback: editable cells show a hover state
- Automatic focus: the input is focused and selected when editing begins
Use Cases:
- Quick data corrections
- Batch data entry
- Spreadsheet-like editing experience
- Real-time updates with backend synchronization
Edit multiple cells across multiple rows and save them individually or all at once:
The schema half is just editable — the save/cancel affordances appear on their
own once a row has pending changes:
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'products',
columns: [
{ field: 'sku', label: 'SKU' },
{ field: 'name', label: 'Name' },
{ field: 'price', label: 'Price', type: 'currency', align: 'right' },
{ field: 'stock', label: 'Stock', type: 'number', align: 'right' },
],
editable: true,
};Left alone, saving goes through the host's data source. A React host that needs to
own persistence supplies onRowSave / onBatchSave as component props — and
because they are props, they take the adapter from the host's own scope rather than
from anything in the schema:
import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';
type Persistence = Pick<ObjectGridComponentProps, 'onRowSave' | 'onBatchSave'>;
const persistence = (
dataSource: NonNullable<ObjectGridComponentProps['dataSource']>,
): Persistence => ({
onRowSave: async (rowIndex, changes, row) => {
await dataSource.update('products', row.id, changes);
},
onBatchSave: async (allChanges) => {
await Promise.all(
allChanges.map(({ row, changes }) => dataSource.update('products', row.id, changes)),
);
},
});Batch Editing Features:
- Pending changes tracking: Edit multiple cells across multiple rows before saving
- Visual indicators:
- Modified rows are highlighted with amber background
- Modified cells are shown in bold with amber text
- Toolbar shows count of modified rows
- Row-level actions: Save or cancel changes for individual rows
- Batch operations:
- "Save All" button to save all modified rows at once
- "Cancel All" button to discard all pending changes
- Flexible callbacks — all three are
ObjectGridComponentProps, never schema keys:onRowSave: called when saving a single rowonBatchSave: called when saving multiple rows at onceonCellChange: called for each staged cell edit
Example Workflow:
- User edits multiple cells across different rows
- Modified rows are visually highlighted
- Toolbar shows "X rows modified" with Save All/Cancel All buttons
- User can:
- Save individual rows using row-level save button
- Save all changes at once using "Save All" button
- Cancel individual row changes or all changes
The schema and column types come from @object-ui/types; this package exports the
component types.
import type { ObjectGridSchema, ListColumn } from '@object-ui/types';
import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';
const nameColumn: ListColumn = {
field: 'name',
label: 'Full Name',
sortable: true
};
const grid: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: [nameColumn],
pagination: { pageSize: 20 }
};
// Row callbacks are COMPONENT props, not schema keys.
const gridProps: ObjectGridComponentProps = {
schema: grid,
onRowClick: (record) => console.log('Row clicked:', record)
};ObjectGridProps is a deprecated alias of ObjectGridComponentProps and denotes the
same type; @objectstack/spec/ui owns the name ObjectGridProps for the authored
props document of the object-grid element (objectui#4650).
MIT — see LICENSE.