#Data Grids
A data_grid is a view over one named source in the page's dataSources
map. The source decides which rows exist; the grid decides how they are shown —
columns, paging, sorting, a text filter per column, row actions, selection. The
grid never fetches anything itself: it names the source, the engine reads one
page of it, and the grid projects that page into its columns.
{
"widgetName": "data_grid",
"config": {
"header": "Fleet",
"dataSource": "vessels",
"pageSize": 3,
"selectable": true,
"columns": [
{
"type": "string",
"field": "name",
"header": "Vessel"
},
{
"type": "string",
"field": "flag",
"header": "Flag",
"lookup": {
"PA": "Panama",
"LR": "Liberia"
}
},
{
"type": "number",
"field": "dwt",
"header": "DWT"
}
],
"actions": [
{
"icon": "openLink",
"command": []
},
{
"menu": [
{
"label": "Edit",
"command": []
},
{
"label": "Archive",
"command": []
}
]
}
]
},
"data": {
"vessels": {
"rows": [
{
"id": "v1",
"attributes": {
"name": "Aurora",
"flag": "PA",
"dwt": 52000
}
},
{
"id": "v2",
"attributes": {
"name": "Borealis",
"flag": "LR",
"dwt": 61000
}
},
{
"id": "v3",
"attributes": {
"name": "Cassiopeia",
"flag": "PA",
"dwt": 38000
}
}
],
"total": 7
}
}
}
The widgets on this page render live; the preview supplies the page the engine
would have read (total: 7 at pageSize: 3 gives a three-page pager).
Interactions are inert here.
#A first grid
Declare the source on the page, then point the grid at it by name:
"dataSources": {
"vessels": {
"$query": {
"typeName": "Vessel",
"filter": { "operator": "ilike", "path": ["attributes", "name"], "value": "MV %" },
"sort": [{ "path": ["attributes", "name"], "direction": "asc" }]
}
}
},
"layout": [["fleet"]],
"widgets": {
"fleet": {
"widgetComponent": "data_grid",
"config": {
"header": "Fleet",
"dataSource": "vessels",
"pageSize": 25,
"columns": [
{ "type": "string", "field": "name", "header": "Vessel" },
{ "type": "number", "field": "dwt", "header": "DWT" }
]
}
}
}
dataSource must be a plain string naming an entry in the same page's
dataSources. Publish refuses anything else:
| Issue code | Cause |
|---|---|
data_source_must_be_reference |
dataSource is an inline source object instead of a name. |
data_source_unknown_reference |
The name is not declared in the page's dataSources. |
grid_schema_source |
The source is a $typeSchema feed (enum values, not rows). |
grid_column_storage_path |
A column field on an entity source is attributes.x-style. |
pipe_server_widget_feed |
A $query/$http feed carries a set-level $pipe step. |
Each arrives as a 422 whose details.issues[] names the code and the path
of the offending key.
#Config reference
| Key | Type | Default | What it does |
|---|---|---|---|
dataSource |
string | — | Required. Name of the page source the grid shows. |
columns |
Column[] |
— | Required. See Columns. |
actions |
RowAction |
Per-row controls. See Row actions. | |
leftActions |
RowAction |
A second group of per-row controls, listed before actions in the same actions column. |
|
selectable |
boolean | false |
Adds a checkbox column. See Selection. |
expand |
boolean | false |
Adds an expand chevron to each row. See Expandable rows. |
header |
string | Wraps the grid in a titled section. | |
noResultsMessage |
string | Text shown in place of the grid when the source has no rows. The grid's (empty) row page still ships, so an earlier page's rows never linger. | |
pageSize |
int 1–200 | 20 |
Rows per page, for the first render and every page request that does not name its own size. |
paginate |
boolean | true |
false shows every row on one page with no pager. See Paging. |
Every value except dataSource may be a smart value ({{ }} or $jsonata).
Columns, actions and menu items may each be $if-gated as array items — a
hidden entry is removed before the grid renders, and a menu whose items are
all hidden is dropped.
#Columns
| Key | Type | What it does |
|---|---|---|
type |
string | number | date | dateTime |
How the client formats and sorts the cell. A date/dateTime column sorts newest-first on the first click. |
field |
string | Which value the cell shows — see below. |
header |
string | Column heading. Omit it for a headerless column; when no column has a header the header band is hidden (select-all stays). |
lookup |
{ raw: label } |
Maps a raw value to a display label. Values with no entry pass through unchanged. |
display |
JSONata | Computes the cell text from value (the cell after lookup) and row (the raw source row). The result is shown as a string. |
sortable |
boolean | false removes the sort affordance. Attribute columns are sortable by default; relationship columns never are. |
filterable |
boolean | false removes the column's text filter. Every column has one by default. |
minWidth |
number | Minimum width in pixels. |
maxWidth |
number | Maximum width in pixels. |
#How field reads a cell
What field means depends on the kind of rows the source produces:
| Source | field reads |
Example |
|---|---|---|
$query |
A bare name reads that attribute (attributes[name]). Type.attr reads attr of the first related entity of type Type — the grid asks the source to include Type for you. id is always there. |
"dwt", "Vessel.name" |
$rows |
The same as $query: rows must be entity-shaped, { "id": …, "attributes": { … } }. |
"name" |
$http |
A dotted path into the raw record. Object and array values render empty. | "owner.name" |
namereads thenameattribute, not the entity's derived display name. If the type has nonameattribute, show the attribute its name is built from.- A relationship column (
Vessel.name), and a dotted column on an$httpsource (owner.name), cannot be sorted. - On the wire each column is identified by its
fieldwith every.replaced by_(Vessel.name→Vessel_name). That column id is the key in the grid'ssortingandfiltersstate.
display sees the raw row, so on an entity source attributes sit under
row.attributes:
{
"type": "string",
"field": "imo_number",
"header": "Flag / IMO",
"display": "row.attributes.flag & ' / ' & value"
}
#Column filters and sorting
A column's text filter matches the raw stored value, not its lookup label
— on the grid above, filtering Flag by LR finds the Liberian vessels;
Liberia finds nothing. How the match runs depends on the source (next
section).
#Choosing a source
| Source | Paging | Column filter | Sorting |
|---|---|---|---|
$query |
By the platform, page by page, with a row total. |
Case- and accent-insensitive substring, or a similarity match on paths the source declares fuzzy under match. |
The clicked column; otherwise the source's own sort. |
$rows |
In memory over the whole array. | Case- and accent-insensitive substring. | Numbers numerically, everything else alphabetically; empty cells last in both directions. |
$http |
In memory over the whole response (up to 5,000 rows). | As $rows. |
As $rows. |
$http paged |
By the upstream API — see Server-paged $http. |
None in memory — map it upstream. | None in memory — map it upstream. |
$typeSchema |
Not a grid source (grid_schema_source). |
The full source vocabulary is in Apps → Data sources. Grid-specific rules:
$query.single: truereturns one record and cannot feed a grid (the grid fails to render with an error notification).countStrategytunes how thetotalis counted. WithdistinctOn: "attributes.<path>"the grid shows one row per distinct value; a distinct source pages by page number only, so acursorbesidedistinctOnfails the grid at render.$rows. An inline or computed array, often{ "$rows": { "$jsonata": "page.data.x" } }.$http. Fetched whole on every render and page request. More than 5,000 rows are cut to the first 5,000 and aWARNINGnotification ("Showing the first 5000 rows …") rides with the grid's rows — filter at the source instead. Give every row a stableid, mapping one with$pipeif the API has none; without it a row's id is its position, which is whatitemIdandselectedRowsthen carry.$pipeon a feed. A grid source may carry only row-localmapsteps. A set-level step (filter,orderBy,distinct, …) would shape one page rather than the result set: on a$queryor$httpsource publish refuses it withpipe_server_widget_feed, and on a$rowssource the grid fails to render. To shape a whole set, give the steps to a source the page reads as a value and feed the grid from it:"dataSources": { "shaped": { "$rows": [ /* … */ ], "$pipe": [{ "filter": "attributes.dwt > 1000" }] }, "shaped_feed": { "$rows": { "$jsonata": "page.data.shaped" } } }The grid names
shaped_feed.
A source filter that reads another widget's value should mark that particle
"optional": true — see
Filtering from outside the grid. A
non-optional particle whose value does not resolve matches nothing, rather
than silently showing every row.
#Paging
The grid asks for pageSize rows (default 20, at most 200) and shows a pager
whenever the page carries a total. On a $query source the platform pages; on
$rows and unpaged $http sources the grid pages the full result in memory.
Either way exactly one page travels to the client.
paginate: false shows every row on one page with no pager and no total. On a
$query source that read is capped at 200 rows, so keep it for short, bounded
lists.
Sort and filter on indexed attributes. A $query grid sorts and filters
through the entity query. Mark each attribute you sort on sortable (and each
you filter on often filterable) in the entity type's indexed-attribute config
— see
Entity type config & query capabilities.
#Server-paged $http
By default an $http source is fetched whole and paged in memory. To let the
upstream API do the paging, build the upstream window from the grid's own state
and declare paged inside $http:
"dataSources": {
"messages": {
"$http": {
"url": "{{ config.app.base_url }}/messages",
"auth": "partner",
"query": {
"page[limit]": "{{ $string($default(page.widgets.inbox.state.data_grid.pageSize, 20)) }}",
"page[offset]": "{{ $string(($max([$default(page.widgets.inbox.state.data_grid.page, 1), 1]) - 1) * $default(page.widgets.inbox.state.data_grid.pageSize, 20)) }}"
},
"rowsPath": "data",
"paged": { "totalPath": "meta.total" }
},
"$pipe": [{ "map": { "id": "{{ id }}", "subject": "{{ attributes.subject }}" } }]
}
},
"widgets": {
"inbox": {
"widgetComponent": "data_grid",
"config": {
"dataSource": "messages",
"pageSize": 20,
"columns": [
{ "type": "string", "field": "subject", "header": "Subject", "sortable": false, "filterable": false }
]
}
}
}
Each page request carries the grid's new page in its state, so the template
always reads the page being asked for. Guard every read with $default(…) so
the first render resolves, and clamp the page with $max([…, 1]) — the client
may ask for page 0 when it means the first page.
What paged means: the rows this call returns are the requested page. The
grid shows them as they are and always gives the page a total, because the
pager appears only when a total is present:
| You declare | total is |
|---|---|
totalPath → a number |
That number. |
hasNextPath → truthy value |
offset + rows on this page + pageSize (one more page). |
hasNextPath → falsy value |
offset + rows on this page (this is the last page). |
| Neither | As hasNextPath, with "a full page" meaning "more". |
offset is (page − 1) × pageSize. Both paths are dotted paths into the
response body, like rowsPath. Without totalPath the total is a lower bound
that grows as the user pages forward and settles on the last page, so declare
totalPath whenever the API reports a count.
- Declare
hasNextPathonly if the API drops its next-page marker on the last page. An API that keeps returning a "next" link past the end would offer another page forever; when in doubt, leave it out — a short or empty page ends the count on its own. - Keep the page size in step. The template's fallback (
20above) must equal the grid'spageSize, because the total is counted in the grid's page size. - Map sorting and filtering upstream. A paged grid does not filter or sort
in memory. Read
page.widgets.<grid>.state.data_grid.filters.<columnId>and.sorting[0].id/.sorting[0].descin the same template, and leave a columnsortable/filterableonly once you have. - Map a stable
id. Without one, a row's id is its position on the page, so ids repeat from page to page.
auth names an outbound HTTP auth profile — see
HTTP Auth & External Data.
#Row actions
actions (and leftActions) is a list of row controls. Each entry is one of
two shapes:
| Shape | Fields | Renders |
|---|---|---|
| Icon action | icon, command, accessor?, hrefAccessor? |
One icon button per row. |
| Menu action | menu: [{ label, command, accessor?, hrefAccessor? }, …] |
An overflow menu per row; each item is a labelled option. |
icon is one of: addOutline, archive, arrowDownward, arrowLeft,
arrowUpward, check, chevronRight, clear, copyFill, delete, edit,
expandList, externalLink, filter, follow, home, loaderCircle,
minus, moreVertical, openLink, search, starFilled, starOutline,
tag, unarchive, unfollow, unlock. Any other name renders as an empty
placeholder.
The command is a normal step sequence that
runs with itemId — the clicked row's id — in scope:
"actions": [
{ "icon": "openLink",
"command": [{ "kind": "navigate", "page": "vessel_detail", "params": { "vessel_id": "{{ itemId }}" }, "modal": true }] },
{ "menu": [
{ "label": "Rename", "command": [{ "kind": "navigate", "page": "rename", "params": { "id": "{{ itemId }}" }, "modal": true }] },
{ "label": "Archive", "command": [ /* … */ ] }
] }
]
accessornames a boolean row field; when it is false for a row, that control is disabled on that row.hrefAccessornames a row field holding a URL; the control opens that link instead of running a command (give it"command": []).- Both names are added to every row automatically, even with no column showing
them — for
$query,$rowsand$httprows alike. On an$httpsource, synthesize a missing field with a$pipemapstep. If the name is also a dotted columnfield(links.url), use a separate flat field for the action: a dotted column's value travels only under its column id (links_url).
{
"widgetName": "data_grid",
"config": {
"dataSource": "inbox",
"columns": [
{
"type": "string",
"field": "subject",
"header": "Subject"
},
{
"type": "string",
"field": "sender.name",
"header": "From"
}
],
"actions": [
{
"icon": "openLink",
"command": [],
"hrefAccessor": "message_href"
}
]
},
"data": {
"inbox": {
"external": true,
"rows": [
{
"id": "m1",
"subject": "Berth confirmed",
"sender": {
"name": "Port Agent"
},
"message_href": "https://example.com/m/1"
},
{
"id": "m2",
"subject": "ETA update",
"sender": {
"name": "Master"
},
"message_href": "https://example.com/m/2"
}
]
}
}
}
Every row control — the expand chevron, then leftActions, then actions —
shares one actions column on the grid.
#Selection and bulk actions
selectable: true adds a checkbox column. The checked row ids are always an
array ([] before anything is checked):
- in the grid's own row commands, as
thisWidget.selectedRows; - anywhere else on the page, as
page.widgets.<gridId>.selectedRows.
Pass the array with $jsonata, or use it inside a {{ }} expression — a bare
{{ thisWidget.selectedRows }} turns it into a string:
{
"label": "Count selected",
"command": [
{
"kind": "notify",
"level": "INFO",
"message": "{{ $string($count(thisWidget.selectedRows)) & ' selected' }}"
}
]
}
More patterns (footer bulk actions, gating a button on a selection) are in
Smart Values → selectedRows.
#Filtering from outside the grid
The grid has no filter bar of its own beyond its column filters. To filter by a
picker, put a chips or filter_bar widget on the page and let the grid's
source read its value through an optional particle:
"dataSources": {
"vessels": {
"$query": {
"typeName": "Vessel",
"filter": { "operator": "and", "filters": [
{ "operator": "ilike", "path": ["attributes", "name"], "value": "MV %" },
{ "operator": "eq", "path": ["attributes", "flag"],
"value": "{{ page.widgets.flag_chips.state.flag }}", "optional": true }
] }
}
}
},
"widgets": {
"flag_chips": { "widgetComponent": "chips", "config": { "id": "flag", "default": "",
"options": [ { "value": "", "label": "All" }, { "value": "PA", "label": "Panama" }, { "value": "LR", "label": "Liberia" } ] } },
"fleet": { "widgetComponent": "data_grid", "config": { "dataSource": "vessels", "columns": [ /* … */ ] } }
}
Nothing links the two widgets. Every action re-renders the page, so the source
re-reads the chip's value; an optional particle whose value is empty, missing
or [] is dropped, so "All" shows every vessel.
The grid returns to page 1. When a widget acts, every other grid whose source reads that widget's state — directly, or through another source it reads — resets before the action's steps run:
pagebecomes 1 and any keyset cursor is dropped;- page size, sorting, column filters and selection are kept;
- a grid never resets on its own actions, and grids that do not read the acting widget keep their page;
- a
setWidgetStatestep on the grid in the same command runs after the reset, so it wins; - a read the engine cannot pin to one widget (
page.widgets[$k],page.widgets.*,$lookup(page.widgets, …)) counts as reading every widget.
So a chip click with the grid on page 3 shows page 1 of the new result, not an empty page 3.
#Expandable rows
expand: true adds a chevron to each row. Clicking it stores that row's id in
the grid's own state as expand (clicking it again stores ""). The grid
renders no detail itself — place a widget that reads the state:
"layout": [
["port_calls"],
[{ "$if": "page.widgets.port_calls.state.expand", "$then": "call_detail" }]
],
"dataSources": {
"calls": { "$query": { "typeName": "PortCall" } },
"call": { "$query": { "typeName": "PortCall", "single": true,
"filter": { "operator": "eq", "path": ["id"], "value": "{{ page.widgets.port_calls.state.expand }}", "optional": true } } }
},
"widgets": {
"port_calls": { "widgetComponent": "data_grid", "config": { "dataSource": "calls", "expand": true, "columns": [ /* … */ ] } },
"call_detail": { "widgetComponent": "headline", "config": { "text": "{{ page.data.call.attributes.port_name }}", "headerLevel": 3 } }
}
A single: true source like call is read as a value (page.data.call); it
cannot itself feed a grid.
#Exporting what a grid shows
A grid has no export button of its own. Add a button (an actions widget, or
the grid's own row menu) whose command exports the same entities to a
spreadsheet, stores the file as a temp file, and hands the user a download link:
"export": {
"widgetComponent": "actions",
"config": { "actions": [ { "label": "Export to Excel", "command": [
{ "kind": "actions", "stepId": "bundle",
"input": { "flag": "{{ page.widgets.flag_chips.state.flag }}" },
"output": { "$jsonata": "{ 'url': outputs.link.url }" },
"actions": [
{ "stepId": "xlsx", "name": "exportEntitiesToXlsx", "config": {
"typeName": "Vessel",
"filter": { "$jsonata": "{ 'operator': 'and', 'filters': $append([ { 'operator': 'ilike', 'path': ['attributes', 'name'], 'value': 'MV %' } ], input.flag ? [ { 'operator': 'eq', 'path': ['attributes', 'flag'], 'value': input.flag } ] : []) }" },
"sort": [{ "path": ["attributes", "name"], "direction": "asc" }],
"columns": { "Vessel": "attributes.name", "Flag": "attributes.flag", "DWT": "attributes.dwt" },
"columnOrder": ["Vessel", "Flag", "DWT"],
"sheetName": "Fleet",
"outputStreamId": "xlsx" } },
{ "stepId": "link", "name": "uploadTempFile", "config": {
"streamId": "xlsx", "filename": "fleet.xlsx",
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ttl": 900 } }
] },
{ "kind": "notify", "level": "INFO", "message": "Your export is ready.",
"button": { "label": "Download", "href": "{{ outputs.bundle.url }}" } }
] } ] }
}
- Action configs resolve against
{ input, outputs }only, so thread page values (here the chip's flag) through the step'sinput. - An action's
filterhas nooptionalparticles: a particle whose value is empty still filters, so "All" ("") would export nothing. Build the filter with$jsonata, as above, adding the flag particle only when a flag is picked. columnsmaps each header toattributes.<path>(or a base field such asidorname—namethere is the entity's derived name). Always givecolumnOrder: the column map's key order is not preserved.arrayStrategy(first_onlyby default,json_array,comma_separated) decides how an array attribute fills one cell;limitcaps the rows.- A workbook is capped at 250,000 cells, 4,000,000 characters of cell text and
16,384 columns; beyond that the step fails.
exportEntitiesToCsvtakes the same config (plusdelimiterandincludeHeader, minussheetName) and streams without those caps — use it for large exports, with"contentType": "text/csv". - The download link is valid for the smaller of the file's
ttland two hours.
The notify step's button renders a Download call-to-action beside the
message; href is resolved when the step runs, so it can point at the file just
created.
#Grid state
A grid's state is readable anywhere on the page under
page.widgets.<gridId>.state:
| Path | Holds |
|---|---|
data_grid.page |
The current page (1-based; a first request may carry 0). |
data_grid.pageSize |
The current page size. |
data_grid.sorting |
[{ "id": "<columnId>", "desc": true | false }]. |
data_grid.filters |
{ "<columnId>": "<typed text>" }. |
data_grid.selectedRows |
Checked row ids (also page.widgets.<gridId>.selectedRows). |
expand |
The expanded row's id, or "" (with expand: true). |
A command can set any of these with a setWidgetState step on the grid:
{
"kind": "setWidgetState",
"widgetId": "fleet",
"state": {
"data_grid": {
"page": 1,
"pageSize": 25,
"sorting": [{ "id": "dwt", "desc": true }],
"filters": {}
}
}
}
#On the wire
A grid's rows never travel inside the rendered surface. They arrive under the
render response's referenceData, keyed by the grid's element id
:p/<pageId>/<gridId>/data_grid: (:m/… in a modal):
"referenceData": {
":p/root/fleet/data_grid:": {
"data": [ { "id": "3d34…", "name": "MV Aurora", "flag": "Panama", "dwt": 52000 } ],
"total": 7
}
}
Each row carries its id, one key per column id (with lookup/display
applied), and any action accessor fields. A $query source's entry may also
carry pageInfo (hasNextPage, hasPreviousPage, endCursor). A page change,
sort or column filter is a page request: the host sends the grid's new state
and a matching referenceData entry, and the answer carries only that grid's
referenceData. A row control's action id is its column entry's id with the row
id appended (:p/root/fleet/actions/0:-<rowId>, …/actions/1/menu/0:-<rowId>
for a menu item). The full request contract is in
Apps → Interactions.
process_data_grid is this grid plus per-step status columns; see the
Widget Catalogue.