#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. Every other widget, and how a page lays them out,
is in the Widget Catalogue and
App Layouts.
#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 grid source other than $rows carries a set-level $pipe step. |
Each arrives as a 422 whose details.issues[] names the code and the path
of the offending key.
A config the grid itself cannot read — a status column with a lookup, a
$-prefixed field that is not a system column — publishes, but the grid is
left out of the page at render and the response carries an ERROR notification.
#Config reference
| Key | Type | Default | What it does |
|---|---|---|---|
dataSource |
string | — | Required. Name of the page source the grid shows. |
columns |
Column[] |
— | Required. See Columns. May be generated ({ "$jsonata": … }). |
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. |
|
fromRow |
string | Path on each row to its decided controls. See Menus of decided controls. | |
fromRowMenu |
object | How the fromRow menu lists its items (items, match, merge). |
|
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. A paged or Load more grid reads at most 100 rows a page, whatever the config says. |
paginate |
true | false | "cursor" | "more" |
true |
How the grid pages. See Paging. |
surfaceKey |
string | Names the surface the grid's sort and filters belong to. See One grid, several surfaces. |
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 | status |
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. |
statusMap |
wording[] | A status column only: how its status views are worded. See Status columns. |
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. |
A missing value shows an empty cell, never the words null or undefined.
#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" |
$job over an entity read |
The same as $query (queryEntity, queryEntityGraph). |
"name" |
$http, any other $job |
A dotted path into the raw record. Object and array values render empty. | "event.path" |
On an entity source, five system columns read the row's own fields rather
than an attribute: $id, $name (the entity's derived name),
$identifier_key, $created_at and $updated_at. Any other $-prefixed
field is refused. When the source sets "includePrincipals": true, four
principal columns name who created and who last changed the row:
created_by.label, created_by.id, updated_by.label and updated_by.id. A
principal column neither sorts nor filters; on a source that does not ask for
principals it is empty.
namereads thenameattribute, not the entity's derived display name — use$namefor that.- A relationship column (
Vessel.name), and a dotted column on an$httpor$jobsource (event.path), cannot be sorted. - On the wire each column is identified by its
fieldwith every.replaced by_(Vessel.name→Vessel_name,created_by.label→created_by_label). 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"
}
#Status columns
A status column shows a status view — the
{ state, label, tone, badges, stateAt } object a process read puts on a row
(see Menus of decided controls) — as text. field
is the path to the view on the raw row (steps.inspection.survey.status). The
cell is the view's label followed by its badge words, joined by ·; a row
with no view at that path shows an empty cell. A status column is never sorted
or filtered, and takes no lookup or display.
statusMap rewords the cell. Each entry names at least one condition — state
(the rung), tone (idle, active, waiting, done, failed) or badge
(one of the view's badge words) — and the first entry whose conditions all
hold decides the cell:
| Key | Meaning |
|---|---|
glyph |
Shown before the label ("✅"). |
label |
The cell's words. {label} is the view's own word for its rung; {date} is the day it reached it (Oct 6, 2026). Without label, the glyph leads the view's own words. |
A view no entry matches shows its own words.
{
"widgetName": "data_grid",
"config": {
"dataSource": "vessels",
"columns": [
{
"type": "string",
"field": "name",
"header": "Vessel"
},
{
"type": "status",
"field": "steps.inspection.survey.status",
"header": "Survey",
"statusMap": [
{
"state": "DONE",
"glyph": "✅",
"label": "{label} {date}"
},
{
"tone": "idle",
"glyph": "○"
}
]
}
]
},
"data": {
"vessels": {
"rows": [
{
"id": "v1",
"attributes": {
"name": "Aurora"
},
"steps": {
"inspection": {
"survey": {
"status": {
"state": "DONE",
"label": "Passed",
"tone": "done",
"badges": [],
"stateAt": "2026-10-06T09:12:00Z"
}
}
}
}
},
{
"id": "v2",
"attributes": {
"name": "Borealis"
},
"steps": {
"inspection": {
"survey": {
"status": {
"state": "IN_PROGRESS",
"label": "Surveying",
"tone": "active",
"badges": [],
"stateAt": "2026-10-05T16:40:00Z"
}
}
}
}
},
{
"id": "v3",
"attributes": {
"name": "Cassiopeia"
},
"steps": {
"inspection": {
"survey": {
"status": {
"state": "READY",
"label": "Not started",
"tone": "idle",
"badges": [],
"stateAt": null
}
}
}
}
}
],
"total": 3
}
}
}
The three cells read ✅ Passed Oct 6, 2026, Surveying and ○ Not started.
#Column filters and sorting
A column's text filter matches the raw stored value, not its lookup label
— on the first 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. |
$job |
In memory over the action's output; with cursor, by the action's own pages — see Rows from an action. |
As $rows (none on a cursor feed). |
As $rows (none on a cursor feed). |
$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.processandcanfold each row's process steps and the viewer's write permissions onto it as row data — see Menus of decided controls and Gating a control per row.$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$query,$httpor$jobsource that feeds a grid may carry only row-local steps,mapandleftJoin. A set-level step (filter,orderBy,distinct, …) would shape one page rather than the result set, so publish refuses it withpipe_server_widget_feed. A$rowssource applies its whole pipe. 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
A grid pages in one of four ways, chosen by paginate:
paginate |
The viewer sees | What ships with the rows |
|---|---|---|
absent or true |
A page-number pager. | total |
"cursor" |
Previous / next buttons. | pageInfo (and total where counted) |
false |
Every row on one page, no pager. | Nothing else |
"more" |
The first pageSize rows and a Load more button below them. |
Nothing else |
Exactly one page of rows travels to the client each time. Every grid asks for
pageSize rows a page (default 20); a paged or Load more grid reads at most 100
rows a page, and its state carries the page size it was read with, which is the
size the client's pager pages by.
The pager (default). On a $query source the platform pages, page by page,
with a row total; on $rows, unpaged $http and $job sources without
cursor, the grid pages the full result in memory. An entity read also hands
out a keyset cursor for each full page; a pager grid keeps it to itself and
pages by page number.
paginate: "cursor" pages by keyset cursor wherever the source hands one
out (a $query source, or a $job source with cursor): the rows arrive with
pageInfo: { hasNextPage, hasPreviousPage, endCursor, … } and the client draws
previous / next. A $job source with cursor is paged by cursor on any grid,
since its action's cursor is the only way to its next page.
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.
paginate: "more" — Load more. The grid shows rows only (no total, no
pager) with a Load more button while more rows follow, and a Back to top
button once more than one page is loaded:
- Each Load more press adds a page of
pageSizerows. Every render re-reads the whole loaded extent from the first row, so a render costs the loaded rows, not one page — up to 1,000 rows. At the cap Load more is not drawn and aWARNINGnotification ("Showing the first 1000 rows — more follow. Narrow the filter to see the rest.") rides with the rows. - Back to top returns the grid to one page.
- A sort or a column filter re-reads the loaded extent, sorted and filtered. Until the next full render, Load more and Back to top keep the state of the last one.
- The source is read as a paged read: a set-level
$pipestep is refused (pipe_server_widget_feed) unless the source is$rows, and the authored$query.limitdoes not bound the extent. - A
$jobsource withcursoris read by chaining the action's pages from the first, at most ten calls a read. When ten pages hold fewer rows than the extent asks for, the grid shows those rows without Load more, and aWARNING("Showing only the rows loaded so far …") says so — raise the action's page size (limit). - The loaded page count rides the grid's state; a widget the source reads acting returns it to one page (see Filtering from outside the grid).
The two buttons are the grid's own controls, pressed like any other element:
// Load the next page of the "history" grid.
{ "user": { … }, "company": { … }, "state": { … }, "action": ":p/root/history/more:" }
// Back to one page.
{ "user": { … }, "company": { … }, "state": { … }, "action": ":p/root/history/top:" }
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.
#Rows from an action: $job
A $job source is the output of one read-only action, run inline as the
viewer each time the page reads it. It is how a page shows what no entity
query returns: an entity's change history, a process's steps and controls, a
permission answer.
"dataSources": {
"history": {
"$job": {
"action": "getEntityTimeline",
"config": { "entityId": "{{ page.params.id }}", "typeName": "Vessel", "limit": 25 },
"rowsPath": "rows",
"cursor": { "in": "cursor", "out": "next_cursor" }
}
}
}
| Key | Meaning |
|---|---|
action |
Required. The action, by its literal name. Only a read-only action may be named (the list below). |
config |
Required. The action's config, written as a record of the action's own keys. Each key's value may be a smart value, resolved at render before the run. |
rowsPath |
Dotted path to the rows in the action's output ("rows", "anchors.0.steps"). Without it the whole output is the value. |
cursor |
{ "in", "out" } — read the action page by page: a page's cursor is written to config.<in>, and the output at <out> is the next page's cursor (absent or empty on the last page). |
The read-only actions are queryEntity, queryEntityGraph,
listEntityArtifacts, createArtifactDownloadURL, downloadTempFile,
createTempFileDownloadURL, pipe, getProcessStatus,
describeProcessActions, describeProcessForm, listProcessTransitions,
checkPermissions and getEntityTimeline. Rows from queryEntity and
queryEntityGraph are entity records, read by attribute like a $query
source's; every other action's rows are plain records, read by dotted path.
Publish refuses (422, details.issues[].code):
| Code | Cause |
|---|---|
job_action_not_readonly |
The action is not one of the read-only actions, or the name is computed. |
job_config_not_literal |
config is computed as a whole ({ "$jsonata": … }) instead of written key by key. |
job_config_unknown_key |
A config key, or cursor.in, is not a key of the action's config — the action would drop it unread. |
How it runs:
- As the viewer, with the viewer's permissions, every time the page reads the source. Identical reads within one request run once; the render after a step that may have written runs the action again.
- Values are data. The config is evaluated at render; its resolved values
reach the action as they are, so a value that looks like an expression is not
evaluated again and no secret is resolved. The action takes no
input, and no outbound auth profile. - Process reads (
describeProcessActions,describeProcessForm,getProcessStatus,listProcessTransitions) answer only in an app session — the/apps/runrender they belong to. - A failure degrades the consumer. An action that fails (a config that does
not validate, an id that is not a uuid) leaves the widget reading the source
out of the page with an
ERRORnotification; the rest of the page renders.
Limits:
| Limit | What happens past it |
|---|---|
5,000 rows from one read without cursor |
Cut to the first 5,000, with a WARNING ("Showing only part of what the data source returned …"). |
200 rows per page of a cursor read |
The action is asked for at most 200 (a larger limit is lowered); a page holding more is cut. |
An action that names a next page, read without cursor |
Only its first page is shown, with a WARNING ("Showing only the first page … Declare the source's cursor to page through the rest."). |
| 10 action calls for one Load more read | The rows loaded so far, with a WARNING — see Paging. |
Without cursor the rows are the whole set and the grid pages them in memory.
With it, the grid pages by the action's cursor — previous / next on a paged
grid, chained from the first page on a "more" grid — and has no in-memory sort
or filter, so give such a grid's columns "sortable": false and
"filterable": false.
#An entity's history: getEntityTimeline
One page of an entity's recorded changes, newest first, read as the viewer.
| Config key | Meaning |
|---|---|
entityId |
Required. The entity. |
typeName |
The entity's type by name (or entityTypeId by id). Name it whenever you know it: the entity is read within that type, and an id the type does not hold is not found. |
eventPaths |
Attribute paths below attributes to keep (["status", "owner.name"]). Absent: every changed attribute. |
limit |
Required. Rows per page, 1–200. |
cursor |
The previous page's next_cursor, bound to the entity and eventPaths it was issued for. |
The output is { "rows": [ … ], "next_cursor"?: "…" } — next_cursor is absent
on the last page — so a source pages it with
"cursor": { "in": "cursor", "out": "next_cursor" }. Each row is one changed
attribute (a change that touched three attributes is three rows):
{
"id": "f7777a44-73d8-49d9-a68b-7a8d40dae00b:0000000002",
"at": "2026-10-06T00:51:53.899439+00:00",
"cursor": "eyJpZCI6…",
"actor": { "kind": "none" },
"subject": {
"type": "Vessel",
"id": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0",
"key": null
},
"event": { "path": "flag", "from": "PA", "to": "LR" }
}
actor is { "kind": "none" }: the action does not name who made a change.
Comments are entities in their own right; read them with a $query, not this
action. A Load more history:
"history_grid": {
"widgetComponent": "data_grid",
"config": {
"dataSource": "history",
"paginate": "more",
"pageSize": 25,
"columns": [
{ "type": "dateTime", "field": "at", "header": "When", "sortable": false, "filterable": false },
{ "type": "string", "field": "event.path", "header": "Changed", "sortable": false, "filterable": false },
{ "type": "string", "field": "event.from", "header": "From", "sortable": false, "filterable": false },
{ "type": "string", "field": "event.to", "header": "To", "sortable": false, "filterable": false }
]
}
}
#What the viewer may do: checkPermissions
Many permission questions in one read, answered for the viewer's session.
| Config key | Meaning |
|---|---|
checks |
Required. 1–1,000 checks; the answers come in the same order. |
Each check:
| Key | Meaning |
|---|---|
resource |
Required. The resource kind: entity, entity_type, relationship_rule, workflow_definition, app_spec, secret, process_definition, and the other kinds the access model grants on. |
action |
Required. create, read, update or delete. |
resourceName |
The type by name. An entity check must name its type, by resourceName or resourceId (not both). A name that resolves to nothing answers false. |
resourceId |
The type (or resource) by id. |
rowId |
Ask about one row (may the viewer delete this vessel?). A row check is about the row's own tenant: for a row shared from another tenant, name that tenant as ownerTenantId. |
ownerTenantId |
The owning tenant. Absent: the viewer's tenant for an entity check. |
payload |
The attributes to judge against grants that filter on attribute values (with rowId, the row's stored attributes when absent). |
postImage / prePayload |
On an update, postImage: true judges payload as the row after the write and prePayload as the row before it. |
The output is { "allowed": [ true, false, … ] }, so "rowsPath": "allowed"
gives a list of booleans to gate controls on:
"dataSources": {
"access": {
"$job": {
"action": "checkPermissions",
"config": { "checks": [
{ "resource": "entity", "resourceName": "Vessel", "action": "update" },
{ "resource": "entity", "resourceName": "Vessel", "action": "delete", "rowId": "{{ page.params.id }}" }
] },
"rowsPath": "allowed"
}
}
}
An actions widget reads page.data.access[0] as an action's enabled — see
Widget Catalogue → actions. An answer is for
display only: every write is judged again when it runs. To gate each row of
a grid, use the source's can instead (next sections).
#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,$rows,$httpand$jobrows alike. On an entity source the name reads an attribute, so map the value underattributeswith a$pipemapstep; on a plain-record source, map it as a top-level field. 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).
#Gating a control per row
A $query source's can folds whether the viewer may write each row onto it,
answered by the page read itself:
"can": [{ "action": "update" }, { "action": "delete", "as": "remove" }] gives
every row can: { "update": true|false, "remove": true|false }. Map those
answers into the attributes the accessors read:
"dataSources": {
"vessels": {
"$query": { "typeName": "Vessel", "can": [{ "action": "update" }, { "action": "delete", "as": "remove" }] },
"$pipe": [{ "map": { "id": "{{ id }}", "attributes": {
"name": "{{ attributes.name }}",
"editable": { "$jsonata": "can.update" },
"removable": { "$jsonata": "can.remove" }
} } }]
}
},
"widgets": {
"fleet": { "widgetComponent": "data_grid", "config": {
"dataSource": "vessels",
"columns": [{ "type": "string", "field": "name", "header": "Vessel" }],
"actions": [
{ "icon": "edit", "accessor": "editable", "command": [ /* … */ ] },
{ "icon": "delete", "accessor": "removable", "command": [ /* … */ ] }
]
} }
}
The answers are for display: the write a control runs is judged again.
#Links in a row
An hrefAccessor field is an ordinary row value. To link to a page of the host
the app is embedded in, build it with $link in the source, and the platform
writes the address the serving host can open:
"$pipe": [{ "map": { "id": "{{ id }}", "attributes": {
"name": "{{ attributes.name }}",
"href": { "$jsonata": "$link('sedna.vessel', { 'id': id })" }
} } }]
Inside the provider that serves the render, href is the bare route
(/fleet/vessels/<id>); anywhere else it is that provider's absolute address
for your tenant, and "" when there is no address to give. See
Widget Catalogue → Links to provider pages.
{
"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, then
the fromRow menu — shares one actions column on the grid.
#Menus of decided controls
A row can carry its own controls, already decided: each one a descriptor
with its words, whether the viewer may press it (enabled), why not (reason),
and what a press does (intent). A process read is where they come from — a
$query source with process folds each row's steps onto it:
"dataSources": {
"vessels": {
"$query": {
"typeName": "Vessel",
"process": { "processes": ["inspection"], "rows": "anchor" }
}
}
}
Each row then holds steps.<process>.<step>.status (a status view) and
steps.<process>.<step>.actions (its descriptors). fromRow names the path to
the descriptors, and the grid adds one row menu of them:
"fleet": {
"widgetComponent": "data_grid",
"config": {
"dataSource": "vessels",
"pageSize": 25,
"columns": [
{ "type": "string", "field": "name", "header": "Vessel" },
{ "type": "status", "field": "steps.inspection.survey.status", "header": "Survey" }
],
"fromRow": "steps.inspection.survey.actions"
}
}
- The menu has one item per distinct control on the page — per transition and label — in key order. Each item is enabled only on the rows whose descriptor of that transition, with that label, is enabled; elsewhere it is disabled.
- A disabled descriptor stays in the menu, disabled; its
reasonis not printed in a row menu. - Rows without the path, or with
nullthere, have no controls; anything else that is not a list of descriptors fails the grid. - The menu sits in the grid's one actions column, after
actions. A row's ownactionsorstatusfield means nothing to the grid unlessfromRowor astatuscolumn names it.
The same keys work on a table whose rows carry
descriptors, and an actions bar draws a list
of them as buttons.
#Listing the menu's items: fromRowMenu
| Key | Meaning |
|---|---|
items |
[{ "key", "label" }] — the menu's items, in this order, on every row whatever the rows hold. Each shows its own label and presses the row's descriptor whose match field is its key; it is disabled on a row that holds no such descriptor enabled. |
match |
transition (default) or label — which descriptor field an item's key names. Needs items. |
merge |
true puts the items at the head of the first menu actions renders, so a row shows one menu. |
"fromRow": "steps.inspection.survey.actions",
"fromRowMenu": {
"items": [
{ "key": "initiate", "label": "Start" },
{ "key": "complete", "label": "Pass" },
{ "key": "undo", "label": "Undo" }
],
"merge": true
},
"actions": [
{ "icon": "externalLink", "command": [], "hrefAccessor": "href" },
{ "menu": [ { "label": "Copy id", "command": [ /* … */ ] } ] }
]
Each row shows one menu — Start, Pass, Undo, then Copy id — with Start, Pass and Undo enabled only where that row's step offers them.
#What a press does
A menu item carries no command of its own. A press reads the source fresh,
finds the clicked row's descriptor for that item, and does what its intent
says — a process move, an undo, or opening a page (a transition that takes
inputs opens its form page; a page the spec declares only as a modal opens as a
modal). The page then re-renders, showing where things stand.
| The fresh read | The answer |
|---|---|
| Holds the descriptor, enabled | It runs. A move is the viewer's own and is judged again when it runs. |
| Holds it disabled | Nothing runs; an ERROR notification in the reason's words, with details.refusal_code set to the reason's code. |
| Does not hold it (the step moved on, the row is gone, the label changed) | Nothing runs; a WARNING notification that the action is not available here any more. |
| The row changed since the page was drawn | Nothing runs; an ERROR notification ("This changed since the page was drawn. Refresh to see where it stands.") with details.refusal_code: "STALE". |
The last row is how two people on one list never act on a step the other just moved: the grid round-trips the version each row was drawn at, and a press from an older page is refused before anything runs. A press is posted like any row control, the item's id with the row's id appended:
{ "user": { … }, "company": { … }, "state": { … },
"action": ":p/root/fleet/fromRow/0/menu/initiate-Start%20survey:-b7fd4216-8dda-455c-a60d-ceb88dafe8b0" }
#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 its first page. 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; a"more"grid goes back to one page of rows;- 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.
#One grid, several surfaces
A list page whose subject the viewer switches in place — a module picker, a chip
per category — often keeps one grid for every subject. Name the subject the grid
is showing with surfaceKey, and each subject keeps its own sort and column
filters:
"fleet": {
"widgetComponent": "data_grid",
"config": {
"dataSource": "vessels",
"surfaceKey": "{{ page.widgets.flag_chips.state.flag }}",
"columns": [ /* … */ ]
}
}
Switching puts the leaving surface's sort and filters away and brings the
arriving one's back (none on a first visit), from page 1 with no selection, at
the grid's own page size. The grid remembers the 20 surfaces left most recently;
the memory rides the round-tripped state, so it is the viewer's own. Without
surfaceKey the grid has one sort and one set of filters for every subject.
A filter_bar whose fields change with the subject keeps its values the same
way with "remember": true — see
Widget Catalogue → filter_bar.
#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 page size the current page was read with (at most 100). |
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). |
data_grid.cursor |
On a "cursor" grid, the cursor the current page was read from. |
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 and
statusMap applied), any action accessor fields, and — with fromRow — one
boolean per menu item (__action_<item>). A "cursor" grid's entry also
carries pageInfo (hasNextPage, hasPreviousPage, endCursor, …); a
"more" or false grid's carries rows only. 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.
// Page 2 of a cursor grid: the cursor is the previous page's endCursor.
{
"user": { … }, "company": { … },
"state": { "__bridge__": "…", ":p/root/fleet/data_grid:": { "page": 2, "pageSize": 10, "cursor": "eyJpZCI6…", "direction": "forward" } },
"referenceData": { ":p/root/fleet/data_grid:": { "filter": { "page": 2, "pageSize": 10, "cursor": "eyJpZCI6…", "direction": "forward" } } }
}
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, …/fromRow/0/menu/<item>:-<rowId> for a decided control). The full
request contract is in
Apps → Interactions.