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

Loading live widget preview…
{
  "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.

  • name reads the name attribute, not the entity's derived display name — use $name for that.
  • A relationship column (Vessel.name), and a dotted column on an $http or $job source (event.path), cannot be sorted.
  • On the wire each column is identified by its field with every . replaced by _ (Vessel.name → Vessel_name, created_by.label → created_by_label). That column id is the key in the grid's sorting and filters state.

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.

Loading live widget preview…
{
  "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: true returns one record and cannot feed a grid (the grid fails to render with an error notification). countStrategy tunes how the total is counted. With distinctOn: "attributes.<path>" the grid shows one row per distinct value; a distinct source pages by page number only, so a cursor beside distinctOn fails the grid at render. process and can fold 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 a WARNING notification ("Showing the first 5000 rows …") rides with the grid's rows — filter at the source instead. Give every row a stable id, mapping one with $pipe if the API has none; without it a row's id is its position, which is what itemId and selectedRows then carry.

  • $pipe on a feed. A $query, $http or $job source that feeds a grid may carry only row-local steps, map and leftJoin. A set-level step (filter, orderBy, distinct, …) would shape one page rather than the result set, so publish refuses it with pipe_server_widget_feed. A $rows source 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 pageSize rows. 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 a WARNING notification ("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 $pipe step is refused (pipe_server_widget_feed) unless the source is $rows, and the authored $query.limit does not bound the extent.
  • A $job source with cursor is 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 a WARNING ("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 hasNextPath only 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 (20 above) must equal the grid's pageSize, 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].desc in the same template, and leave a column sortable / filterable only 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/run render 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 ERROR notification; 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": [ /* … */ ] }
  ] }
]
  • accessor names a boolean row field; when it is false for a row, that control is disabled on that row.
  • hrefAccessor names 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, $http and $job rows alike. On an entity source the name reads an attribute, so map the value under attributes with a $pipe map step; on a plain-record source, map it as a top-level field. If the name is also a dotted column field (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.

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.

Loading live widget preview…
{
  "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.

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 reason is not printed in a row menu.
  • Rows without the path, or with null there, 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 own actions or status field means nothing to the grid unless fromRow or a status column 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:

  • page becomes 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 setWidgetState step 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's input.
  • An action's filter has no optional particles: 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.
  • columns maps each header to attributes.<path> (or a base field such as id or name — name there is the entity's derived name). Always give columnOrder: the column map's key order is not preserved.
  • arrayStrategy (first_only by default, json_array, comma_separated) decides how an array attribute fills one cell; limit caps 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. exportEntitiesToCsv takes the same config (plus delimiter and includeHeader, minus sheetName) 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 ttl and 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.