Loopfour

Dashboard component reference

Reference for dashboard widgets: the JSON shape of each component, its settings and how it reads data from a table into a chart or grid.

A dashboard widget is one JSON spec that lists components and binds each figure to a query on a Loopfour table. Chat writes these specs for most users; this page is for developers who write or edit them through the API. For the plain-language guide, see Charts and tables you can use.

The same component library renders dashboards in Studio, chart previews in chat and public share pages.

Widget envelope

Create a widget with POST /api/v1/dashboards/{id}/widgets (scope dashboards:write). The body is strict: unknown keys are rejected.

FieldTypeRequiredMeaning
kind"json-render"NoThe only widget kind. Defaults to "json-render".
config.version1NoConfig format version. Defaults to 1.
config.titlestring, up to 120 charactersNoSection title shown on the dashboard.
config.specobjectYesThe spec described below.
position.w, position.hinteger 1 to 12NoSize hints. The server assigns the coordinates.

PATCH /api/v1/dashboards/{id}/widgets/{widgetId} replaces the whole config; it is not a partial merge of the spec. See the Dashboards API for the other endpoints.

Spec shape

KeyMeaning
rootThe id of the top element. It must exist in elements.
elementsA map of element id to { type, props, children, on?, watch? }. type is a component name from the catalog below.
stateOptional. May only seed filters, for example a default period. Any other key is rejected.

How figures get their data

Figures do not hold literal numbers. A loadData action queries one table and writes the result into state at /data/<key>. Components read it with a {"$state": "/data/<key>/rows"} binding, or /data/<key>/value for a single aggregate.

  • Put the initial loads on the root element under on.mount.
  • To re-run loads when a filter changes, add a watch entry per filter path on the root element, for example /filters/from and /filters/to.

loadData parameters

ParamTypeMeaning
targetstring/data/<key>, letters, digits, _ and - only.
tableIdUUIDA literal table id. A state reference is rejected.
aggregate{ fn, columnId? }fn is count, count_distinct, sum, avg, min or max. With an aggregate the result is [{label, value}], and a single-row result also fills /data/<key>/value.
groupBystringColumn id to group by.
timeGrainstringday, week, month, quarter or year, for a date groupBy.
orderstringlabel_asc, label_desc, value_asc or value_desc.
limitinteger 1 to 500Rows or groups returned.
dateColumnstringColumn id the period filter applies to.
from, to"YYYY-MM" or {"$state": ...}The period, usually bound to /filters/from and /filters/to.
searchColumnstringColumn id a search box or dropdown filters.
searchstring or {"$state": ...}The filter value.
searchOperatorstringcontains for a search box, equals for a dropdown.

Column params (columnId, groupBy, dateColumn, searchColumn) take column ids, not display names. Without aggregate, the result is raw rows, and each row's fields are keyed by column name; a component reads them with keys such as labelKey or a table column's key.

Other actions

ActionWhereParams
exportCsvon{ statePath: "/data/<key>/rows", filename? }
navigateon{ path }, an in-app path
setStateonMust write a path under /filters/

watch accepts loadData only, and a watch path cannot be under /data.

Components

Top-level optional props accept null. Each titled component also takes the header props.

Layout and text

ComponentProps
Stackdirection (horizontal, vertical), gap (sm, md, lg), align (start, center, between). Has children.
Gridcolumns (a number or "auto"), gap. Has children. "auto" fits a row of KPI tiles.
Cardtitle, description. Has children.
Headingtext, level (h1, h2, h3).
Texttext, muted.
Badgetext, tone (neutral, success, warning, danger).
Callouttext, title, tone (info, success, warning).
Buttonlabel, variant (primary, secondary, ghost). Bind an action under on.press.

Figures and charts

ComponentUse it forKey props
MetricOne KPI tilelabel, value, format (required), compareValue, compareLabel, delta (mode, invert, neutralWithin), sparkline, target, targetLabel, thresholds
BarChartComparisons by period or categoryCartesian props, plus layout (vertical, horizontal), stacked, signed, netLine, showValues
LineChartTrends over timeCartesian props, plus forecastFrom ("YYYY-MM"), band (lower, upper, label)
AreaChartCumulative or volume trendsSame as LineChart
ComboChartBars and lines on one chartCartesian props, plus rightFormat. Each series sets kind and optionally axis: "right"
PieChartShare of a totaltitle, data, format, height. Shows at most 5 slices; the rest group into "Other"
BarListRankingstitle, data, labelKey, valueKey, compareKey, format, limit (1 to 50), otherLabel, showShare, color, sort
WaterfallChartBridgestitle, data, labelKey, valueKey, mode (delta, absolute), startLabel, endLabel, subtotalsAfter, format, colors (increase, decrease, total), sort, height

Cartesian props: title (required), description, data (one-series shortcut), series (up to 8), format, color, height (sm, md, lg), yDomain (min, max, zero), legend.

A series is { key, label, data, kind?, color?, role?, stack?, axis? }. role is actual, compare, budget, forecast or variance.

Tables

ComponentUse it forKey props
DataTableRow lists and statementstitle, data, columns (up to 40, required), searchable, searchStatePath, pageSize, rowRoleKey, indentKey, footer (totals, none), stickyFirstColumn, defaultSort
PivotTableCross-tabstitle, data, rowField, columnField, valueField (all required), rowFieldLabel, rowGroupField, aggregate, format, totals, rowSort, columnSort, thresholds, collapsible
VarianceTableBudget against actualtitle, data, labelKey, actualKey, budgetKey, favorable (all required), rowRoleKey, indentKey, format, showPercent, tolerance
CohortHeatmapRetention or revenue by cohorttitle, data, cohortKey, periodKey, valueKey (all required), measure, normalizeTo, maxPeriods (1 to 36), format, scale, color, thresholds

A DataTable column is { key, label, format?, width?, align?, sticky?, thresholds?, total?, negativeColor? }. total is sum, avg or none. A column format also accepts month and text.

rowRoleKey names the row field that holds header, item, subtotal or total; indentKey names the field that holds an indent level from 0 to 3. Together they lay out a statement.

VarianceTable.favorable is higher, lower or the name of a row field that holds one of those per row.

Filters

ComponentProps
DateRangeFilterfromStatePath, toStatePath (point them at /filters/from and /filters/to), presets, compare
SearchInputstatePath, placeholder, label
SelectstatePath, options ([{ value, label }]), label, kind (entity, currency, generic)

Presets: mtd, qtd, ytd, last_3_months, last_6_months, last_12_months, fiscal_ytd, last_fiscal_year, closed_period, all_time.

With compare: true the filter also writes /filters/compare, /filters/compareFrom and /filters/compareTo. Bind a second load to the compare range to feed Metric.compareValue.

When a spec does not seed them, the filters default to the last 12 months ending this month, with compare set to the previous period.

Shared settings

Number format

A format is a legacy string (currency, compact_currency, percent, number) or an object:

FieldValues
kindcurrency, percent, pp, number, integer, months, days, ratio (required)
currencyISO-4217 code, for example EUR
scaleauto, none, thousands, millions, billions
decimals0 to 4
negativeminus or parentheses
signDisplayauto or always

Missing values show —. A percent change on a zero base shows N/M. Fields left out fall back to the dashboard settings.

Color tokens

Specs name tokens, not hex values:

  • Series: series-1 to series-8 (chart-1 to chart-5 are kept as aliases).
  • Status: positive, negative, neutral, warning.
  • Roles: compare, forecast.

Thresholds

Up to 8 rules, { op, value, value2?, color, label? }. op is lt, lte, gt, gte or between (which needs value2). The first matching rule wins.

Sort and totals

A sort is { by, direction }, where by is label, value or a column key and direction is asc or desc. PivotTable.totals is both, row, column, none, or { row?, column?, label? }.

Header props

PropMeaning
subtitleOne muted line under the title, up to 160 characters.
definitionTooltip text on the title, up to 500 characters.
basisA short basis label, such as Accrual, up to 40 characters.
showAsOfShow when the figure last loaded.
actionsUp to two of export (download the rows as CSV) and open (link to the source table). open is hidden on public share pages.

Dashboard settings

Each dashboard has one set of display defaults. A component format overrides them field by field. Set them with PATCH /api/v1/dashboards/{id} and a settings object; only the keys you send change.

SettingValuesDefault
localeBCP-47 tag, for example en-GBen-US
currencyISO-4217 codeUSD
negativeminus or parenthesesminus
fiscalYearStartMonth1 to 121
densitycompact or comfortablecompact

The currency setting changes display only. It does not convert amounts.

Example

A receivables widget on a table of open invoices: a period filter, a total, the top customers and the invoice list. The table id and column ids are placeholders; take the real ones from the Tables API.

{
  "kind": "json-render",
  "config": {
    "version": 1,
    "title": "Receivables overview",
    "spec": {
      "root": "page",
      "elements": {
        "page": {
          "type": "Stack",
          "props": { "direction": "vertical", "gap": "md" },
          "on": {
            "mount": [
              { "action": "loadData", "params": { "target": "/data/open_ar", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "aggregate": { "fn": "sum", "columnId": "col_amount" }, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } },
              { "action": "loadData", "params": { "target": "/data/by_customer", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "aggregate": { "fn": "sum", "columnId": "col_amount" }, "groupBy": "col_customer", "order": "value_desc", "limit": 10, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } },
              { "action": "loadData", "params": { "target": "/data/invoices", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "limit": 100, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } }
            ]
          },
          "watch": {
            "/filters/from": [
              { "action": "loadData", "params": { "target": "/data/open_ar", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "aggregate": { "fn": "sum", "columnId": "col_amount" }, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } },
              { "action": "loadData", "params": { "target": "/data/by_customer", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "aggregate": { "fn": "sum", "columnId": "col_amount" }, "groupBy": "col_customer", "order": "value_desc", "limit": 10, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } },
              { "action": "loadData", "params": { "target": "/data/invoices", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "limit": 100, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } }
            ],
            "/filters/to": [
              { "action": "loadData", "params": { "target": "/data/open_ar", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "aggregate": { "fn": "sum", "columnId": "col_amount" }, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } },
              { "action": "loadData", "params": { "target": "/data/by_customer", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "aggregate": { "fn": "sum", "columnId": "col_amount" }, "groupBy": "col_customer", "order": "value_desc", "limit": 10, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } },
              { "action": "loadData", "params": { "target": "/data/invoices", "tableId": "3f2b9c1e-7a4d-4e8b-9c2f-1d5e6a7b8c9d", "limit": 100, "dateColumn": "col_due_date", "from": { "$state": "/filters/from" }, "to": { "$state": "/filters/to" } } }
            ]
          },
          "children": ["filter", "open_ar", "top_customers", "invoices"]
        },
        "filter": {
          "type": "DateRangeFilter",
          "props": { "fromStatePath": "/filters/from", "toStatePath": "/filters/to", "presets": ["mtd", "qtd", "ytd", "last_12_months"], "compare": false },
          "children": []
        },
        "open_ar": {
          "type": "Metric",
          "props": { "label": "Open receivables", "value": { "$state": "/data/open_ar/value" }, "format": { "kind": "currency", "scale": "auto" }, "definition": "Sum of open invoice amounts due in the selected period." },
          "children": []
        },
        "top_customers": {
          "type": "BarList",
          "props": { "title": "Top customers by open balance", "data": { "$state": "/data/by_customer/rows" }, "format": { "kind": "currency", "scale": "auto" }, "limit": 10, "showShare": true },
          "children": []
        },
        "invoices": {
          "type": "DataTable",
          "props": {
            "title": "Open invoices",
            "data": { "$state": "/data/invoices/rows" },
            "columns": [
              { "key": "customer", "label": "Customer" },
              { "key": "invoice_number", "label": "Invoice" },
              { "key": "due_date", "label": "Due", "format": "text" },
              { "key": "amount", "label": "Amount", "format": { "kind": "currency", "negative": "parentheses" }, "align": "right", "total": "sum" }
            ],
            "footer": "totals",
            "actions": ["export", "open"]
          },
          "children": []
        }
      }
    }
  }
}

To check a spec without saving it, send { "spec": ... } to POST /api/v1/dashboards/specs/check (scope dashboards:read). It runs the same checks as a widget write, including that the tables and columns exist in your company, and returns { "ok": true } or { "ok": false, "issues": [...] }.

Validation and limits

A widget write is rejected when the spec breaks one of these rules:

  • A figure prop (Metric.value, compareValue, sparkline, chart data, series[].data, band.lower, band.upper, table data, DataTable.rowCount) holds a literal instead of a /data/... binding.
  • A component reads /data/<key> that no loadData targets, or an action reads a state path that is not defined.
  • An action outside the lists above, or a watch with anything other than loadData.
  • A tableId that is not a literal UUID, or a table or column that does not exist in your company.
  • The spec is larger than 256 KB, or has more than 50 load bindings.
  • One refresh of the widget needs more than 20 table reads. An aggregate load counts as one read; a rows load counts one per 100 rows of limit.
  • A chart has more than 8 series, or a between threshold has no value2.

Dashboard-wide limits: 40 widgets, 200 load bindings across all widgets, and 2 MB of spec JSON in total.

Public share pages

When a dashboard has a public link, GET /api/v1/dashboards/share/{token} returns a redacted copy of each spec: table ids, column ids and literal filter values are replaced with opaque targets. The page then asks POST /api/v1/dashboards/share/{token}/data for each figure by widget and target, and the server runs only the query stored in that widget. Responses carry Cache-Control: public, s-maxage=60, and an open share page reloads its figures every 5 minutes. After the link is revoked, both endpoints return 410 with "code": "GONE".

On this page