Skip to content

Latest commit

 

History

History
587 lines (478 loc) · 21.5 KB

File metadata and controls

587 lines (478 loc) · 21.5 KB
title Plugin Grid

import { InteractiveDemo } from '@/app/components/InteractiveDemo'; import { PluginLoader } from '@/app/components/PluginLoader';

Advanced data grid with sorting, filtering, pagination, and row selection capabilities.

Installation

npm install @object-ui/plugin-grid

This package publishes a stylesheet. Import it after the base sheets, or the grid renders unstyled — the themed utilities it uses have no other source in a published app (#4929):

/* src/index.css */
@import "tailwindcss";
@import "@object-ui/components/style.css";
@import "@object-ui/fields/style.css";
@import "@object-ui/plugin-grid/style.css";

<PluginLoader plugins={['grid']}>

Interactive Examples

Each preview below is an object-grid node drawn by this plugin — the JSON in the Code tab is the whole example, and the rows on screen came out of a find() call, not out of that JSON. The records are served by the docs site's demo data source, because dataSource is not a schema key: it is the prop the registered renderer pulls off SchemaRendererProvider context (see ObjectQL Integration below), so in your own app these same nodes read whatever object your data source serves. That fixture answers search and sort but not filters, which is the only reason no filter is authored here.

Columns, sort and search

Selection, row actions and column summaries

Features

  • Sorting - Multi-column sorting support
  • Filtering - Column-level filtering
  • Pagination - Built-in pagination controls
  • Row Selection - Single and multi-row selection
  • Custom Cells - Custom cell renderers
  • Responsive - Mobile-friendly layouts

Schema API

Grid

A grid node is an ObjectGridSchema: one required objectName, plus keys drawn from the list this plugin declares as its authoring surface (GRID_QUERY_INPUTS, packages/plugin-grid/src/index.tsx) — 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.

{
  "type": "object-grid",
  "objectName": "users",
  "columns": ["name", "email"],
  "sort": [{ "field": "created", "order": "desc" }],
  "pagination": { "pageSize": 20 }
}

Note the type. It is object-grid (or view:grid), never grid — bare grid is the CSS Grid layout container from @object-ui/components, whose columns is a column count. See Registration below for why this plugin deliberately does not claim it.

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 file title.
filter ViewFilterRule[] Baked into the query, 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 puts the search box in the toolbar.
data ViewData Inline rows that bypass the object query — see Inline data.
selection SelectionConfig { type: 'none' | 'single' | 'multiple' }.
rowActions / bulkActions string[] Names of actions, not inline 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 two 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.

Column Definition

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's 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, type? } 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. header and accessorKey are not softer spellings of label and field; they fail validation.

Column Summaries

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.

Usage

Registration is a side effect of the import

import '@object-ui/plugin-grid';

That single import is the whole of registration — there is no components map to iterate over. Importing the entry runs the three ComponentRegistry.register(...) calls in packages/plugin-grid/src/index.tsx, which claim exactly these schema types:

Namespaced key Bare-name fallback Renderer behind it
plugin-grid:object-grid object-grid ObjectGridRenderer — the data grid, queried from an object
view:grid none — skipFallback: true the same renderer, under the view protocol
plugin-grid:import-wizard import-wizard ImportWizardRenderer — spreadsheet / clipboard import

ComponentRegistry.register publishes namespace:type, and — unless the call passes skipFallback: true — the bare type as a back-compat fallback (packages/core/src/registry/Registry.ts:194, fallback at :226).

Bare grid is deliberately not this plugin's. skipFallback: true on the view:grid call keeps the data grid from claiming it, because grid belongs to the CSS Grid layout container in @object-ui/components (packages/components/src/renderers/layout/grid.tsx:50). Reach the data grid as object-grid, or as view:grid when you want the namespaced spelling.

Registering the renderer under your own key

To serve the data grid under a key of your own, register the exported renderer — that is what a manual registration is here:

import { ComponentRegistry } from '@object-ui/core';
import { ObjectGridRenderer } from '@object-ui/plugin-grid';

ComponentRegistry.register('my-grid', ObjectGridRenderer, {
  namespace: 'my-app',
  label: 'My Grid',
  category: 'plugin',
});

ObjectGrid, ObjectGridRenderer, VirtualGrid, SplitPaneGrid, ImportWizard, InlineEditing and FormulaBar are on the package's export surface, alongside the hooks (useGroupedData, useColumnSummary, useCellClipboard, …) and the component prop types (ObjectGridComponentProps, VirtualGridProps, …). There is no aggregate map among them, and the schema types are not here either — they live in @object-ui/types, because the schema is the shared authoring contract rather than this package's component API.

Examples

Basic Grid

{
  "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 }
}

Cell appearance

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.

{
  "type": "object-grid",
  "objectName": "opportunities",
  "columns": [
    { "field": "stage", "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.

Inline data

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.

{
  "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" }
    ]
  }
}

Selectable Grid

{
  "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.

Grid with Pagination

{
  "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.

ObjectQL Integration

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).

{
  "type": "object-grid",
  "objectName": "users",
  "columns": [
    { "field": "name", "label": "Name" },
    { "field": "email", "label": "Email" },
    { "field": "created_at", "label": "Created", "type": "datetime" }
  ],
  "filter": [{ "field": "status", "operator": "equals", "value": "active" }],
  "searchableFields": ["name", "email"],
  "pagination": { "pageSize": 20 }
}

The adapter itself is not a schema key: a schema is a serialisable document, while a live adapter is an object with methods. The grid reads its adapter from React context, which the host installs once above the whole tree with <SchemaRendererProvider dataSource={...} />.

Features

Sorting

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. There is no top-level sortable switch.

{
  "type": "object-grid",
  "objectName": "users",
  "sort": [{ "field": "created", "order": "desc" }],
  "columns": [
    { "field": "name", "label": "Name" },
    { "field": "email", "label": "Email", "sortable": false }
  ]
}

Filtering and search

There is no per-column filter key and no top-level filterable switch. A grid narrows its query two ways: a filter baked into the metadata, and a toolbar search over the fields named in searchableFields.

{
  "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"]
}

Row Actions

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 carrying callbacks.

{
  "type": "object-grid",
  "objectName": "users",
  "columns": ["name", "email"],
  "rowActions": ["view", "edit", "delete"],
  "selection": { "type": "multiple" },
  "bulkActions": ["delete", "export"]
}

Row callbacks are component props

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 of them 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: its mode decides what a row click does without any host code.

Inline Editing

Enable inline cell editing for quick data updates:

{
  "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.

Features:

  • Double-click to edit: double-click any editable cell to enter edit mode (singleClickEdit: true opens it on the first click instead)
  • Keyboard shortcuts: press Enter on a focused cell to start editing, Enter again to save, Escape to cancel
  • 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. There is no editable key on ListColumn.
  • Visual feedback: editable cells show a hover state, and the input is focused and selected when editing begins

To own persistence in a React host, supply onCellChange as a component prop — it is not a schema key.

Batch Editing & Multi-Row Save

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:

{
  "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)),
    );
  },
});

Features:

  • Pending changes tracking: edit multiple cells across rows before saving
  • Visual indicators: modified rows highlighted in amber, modified cells in bold
  • Row-level save/cancel: individual row save and cancel buttons
  • Batch operations: Save All and Cancel All buttons for bulk actions
  • Flexible callbacks — all three are ObjectGridComponentProps, never schema keys: onRowSave for a single row, onBatchSave for many, onCellChange for each staged cell edit

TypeScript Support

The schema and column types come from @object-ui/types; this package exports the component types. Neither GridSchema nor GridColumn is on this package's export surface, and both names are taken elsewhere by different things — GridSchema in @object-ui/types is the CSS Grid layout container, and GridColumn in @object-ui/fields is a column of the line-items form widget (keyed name). The data-grid pair is ObjectGridSchema + ListColumn.

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)
};

Annotating the literal 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.

License

MIT