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

#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"
  • name reads the name attribute, not the entity's derived display name. If the type has no name attribute, show the attribute its name is built from.
  • A relationship column (Vessel.name), and a dotted column on an $http source (owner.name), cannot be sorted.
  • On the wire each column is identified by its field with every . replaced by _ (Vessel.nameVessel_name). 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"
}

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

  • $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 grid source may carry only row-local map steps. A set-level step (filter, orderBy, distinct, …) would shape one page rather than the result set: on a $query or $http source publish refuses it with pipe_server_widget_feed, and on a $rows source 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 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.

#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 and $http rows alike. On an $http source, synthesize a missing field with a $pipe map step. 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).
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 — 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:

  • page becomes 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 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.

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