Skip to main content

JSON schema & components breakdown

This technical doc follows the breakdown of the JSON schema and the Widgets the feature currently supports.

Note that the JSON should be built from the Studio Builder as best practice.

This is for the frontend and backend code references and feature implementation.


1. What happens when someone opens a dashboard

Step by step:

1. The route loads. app.dashboards._index calls GET /dashboard_configs/current, sending the auth token and an X-Company-Id header.

2. Rails picks a row. DashboardConfig.resolve_for takes the company's active config, newest first, or falls back to the global default.

3. The Resolver rewrites the config in four passes:

Pass 3 recurses into group.children and tabbedGroup.tabs[].widget. A tab left with zero widgets is dropped too. No config at all raises NotFound404.

4. The response goes out, cached privately for 60 seconds:

GET /dashboard_configs/current — response envelope
{
"data": {
"dashboard_config_id": 41,
"name": "Mitgo — Transportation & Invoicing",
"app_mode": "edi",
"company_id": 812,
"is_default": false,
"config": { "version": 2, "tabs": [ ] }
}
}

5. The renderer takes over.

  • DataSourcesContext fires each data source once and caches it. Ten widgets reading "main" cause one network call.
  • useFilterState syncs filters to the URL, so a filtered dashboard is shareable.
  • bindings.ts resolves every marker before anything reaches ECharts.
  • WidgetShell gives each widget loading / empty / error chrome plus a Sentry-tagged error boundary. One broken widget doesn't take the page down.

2. How a config is shaped

Top level

config — the four root fields
{
"version": 2, // always 2 — v1 is rejected
"name": "Mitgo EDI Dashboard", // admin-facing label
"inheritDefaultTabs": "none", // "none" | "prepend" | "append"
"tabs": [ /* at least one */ ]
}
FieldNotes
versionMust be 2.
nameOptional. Shown in the Builder list, never to end users.
inheritDefaultTabs"prepend" puts the default's tabs before yours, "append" after. Deduped by key.
tabsRequired, non-empty. May hold $inherit placeholders.

3. Tab

tab — a top-level dashboard tab
{
"key": "transportation", // unique; also the URL param
"labelKey": "edi.dashboard.transportation", // translation key
"icon": "LocalShipping",
"isActive": true, // false = kept, never rendered
"requiredPermissions": ["EDI-TRANSPORT"], // fail → whole tab dropped
"dataSources": { "main": { /* … */ } },
"filters": [ /* … */ ],
"actions": [ /* … */ ],
"widgets": [ /* required, non-empty */ ]
}

isActive: false is the soft off-switch — park a tab without deleting the work.

Inheriting a tab

$inherit — a tab placeholder
{ "$inherit": { "from": "default", "key": "transfer_volume" } }

At render time this becomes that tab from the app-mode default, as-is (no deep merge in v2). A placeholder pointing at a missing or inactive tab is silently dropped. Use placeholders instead of inheritDefaultTabs when you care where the inherited tab sits in the order.


4. DataSource

A named HTTP call, cached per tab and shared by every widget that names it.

dataSources.<name> — endpoint, method, params
{
"endpoint": "/dashboards/mitgo_transportation", // must be an in-app path
"method": "GET",
"params": {
"start_date": { "$filter": "period.start" },
"end_date": { "$filter": "period.end" },
"company_id": { "$company": true }
},
"requiredPermissions": ["EDI-TRANSPORT"]
}
endpoint must start with /

Absolute URLs, protocol-relative URLs (//host), backslashes, and whitespace are all rejected. The reason is concrete: the browser attaches the user's session token as a default header and ignores baseURL for absolute URLs — so a config naming an external host would ship that token there, for every user who opens the dashboard. isSafeDashboardPath enforces the same rule again at render time, so a config that somehow got stored with a bad endpoint still can't leak.

Path syntax

Paths are anchored at a data source name:

PathResolves to
mainthe whole payload
main.cards.employees_countnested object access
main.years_period[0].net_salaryone array element
main.years_period[*].net_salarymap over the array, pluck the field
main.partners.204_by_partner[*]map, giving an array of objects

Missing nodes resolve to null / []. Nothing throws.

Most endpoints return {"data": {…}}. The renderer unwraps that envelope, so write main.kpis, not main.data.kpis.

Param values

MarkerResolves to
a literal ("2026", 42)itself
{ "$filter": "<key>" }the current filter value; dropped when empty
{ "$today": "YYYY-MM-DD" }today, in that format
{ "$company": true }the numeric company id from the header

A dateRange filter holds two halves — address them with dotted keys: period.start and period.end.


5. Filter

Filters render above the widget grid and write their values into the URL, using key as the param name. A default applies only when the URL has no value yet.

filter — a toolbar input, options fed by a data source
{
"type": "select",
"key": "origin_city",
"labelKey": "edi.dashboard.origin_city",
"options": {
"from": "cities", // a DataSource on this tab
"path": "", // "" = the whole payload is the option array
"valueKey": "code",
"labelKey": "name",
"dependsOn": "origin_state" // reset when that filter changes
},
"default": { "from": "main", "path": "current_period.payroll_period_id" }
}
typeUI
selectsingle dropdown
multiSelectmulti-value, comma-separated in the URL
groupedSelectdropdown with groupKey headers
dateRangetwo URL params, <key>_start and <key>_end
datesingle date
textfree text
numbernumeric input

6. Action

Buttons in the tab toolbar.

action — a toolbar button
{
"key": "export_csv",
"labelKey": "edi.dashboard.export",
"kind": "exportCsv",
"target": "/dashboards/shipment_analytics.csv",
"iconName": "Download",
"variant": "outlined", // plain | soft | outlined | solid
"color": "neutral",
"confirm": { "titleKey": "…", "messageKey": "…" }
}

Each kind needs a different field, and the Validator checks the right one:

kindDoesNeeds
linkclient-side navigationtarget
apiCallfires method + target; honors confirm; toaststarget
downloadsame, expects a blobtarget
exportCsvbuilds a CSV in the browser, or downloads from targetcsv or target
openModalopens a host-registered modalmodalId
applyFiltersets a filter valuefilterKey

Give exportCsv a csv spec and no request happens at all — the file is built from the payload already in memory:

csv — a client-side CSV spec
"csv": {
"sections": [
{
"mode": "rows", // "keyValue" | "rows" | "parallel"
"rowsPath": "amounts_by_partner", // required when mode is "rows"
"columns": [
{ "headerKey": "edi.dashboard.partner", "valuePath": "partner_name" },
{ "headerKey": "edi.dashboard.amount", "valuePath": "total_amount" }
]
}
]
}

7. Widget — the common part

Every widget has these, whatever its type:

widget — the fields shared by all fifteen types
{
"key": "transport_kpis", // unique within the tab
"name": "Transportation KPI row", // admin-only label, Builder list
"type": "statCards",
"grid": { "xs": 12, "sm": 6, "lg": 3 }, // 12-column, MUI Joy breakpoints
"labelKey": "edi.dashboard.kpis", // OR "label": [ … ]
"iconName": "MultilineChart",
"titleColor": "primary",
"requiredPermissions": [],
"source": "main", // omit for "group" / "tabbedGroup"
"emptyStateKey": "edi.dashboard.no_data" // defaults to dashboard.widget.empty
}

Use name. It never reaches end users — it's how you tell six bar charts apart in the Builder.

If a widget's source was dropped by permission filtering, the widget goes too.

Label expressions. Static is labelKey. When the title needs live data, build it from parts:

label — a title assembled from parts
"label": [
{ "$tr": "payroll.dashboard.ytd_payroll_costs" },
" ",
{ "$ref": "main.ytd.payroll_year" }
]

8. The widget catalog

Fifteen types, in five palette categories.

KPIs & lists

statCards — a row of KPI cards
{
"type": "statCards",
"source": "main",
"basePath": "kpis", // prefixes every valuePath below
"layout": "grid", // "grid" (default) | "list"
"cards": [
{
"key": "lt_total",
"labelKey": "edi.dashboard.count_of_204",
"valuePath": "count_of_204",
"format": "integer",
"iconName": "LocalShipping",
"color": "primary",
"requiredPermissions": []
}
]
}

Four cards is the practical maximum in grid layout. Cards carry their own permissions; if all of them drop, the widget drops.

Dynamic cards. When the KPIs differ per company, expand an array in the data into cards instead of hard-coding them:

"dynamicCards": {
"itemsPath": "transaction_set_counts", // array, relative to basePath
"setKey": "set",
"valueKey": "count",
"labelKeyKey": "labelKey", // server-built translation key, if any
"labelKeyTemplate": "edi.dashboard.count_of_{set}",
"labelTemplate": "EDI {set}", // plain-text fallback
"format": "integer",
"iconName": "FindInPage",
"colors": ["primary", "success", "warning", "neutral"] // cycled
}

Label resolution per item: the item's labelKeylabelKeyTemplate with {set} substituted → translation lookup → labelTemplate → the raw set name. So a new transaction set renders as "EDI 940" the day it appears, and switches to the real title once the key lands in the locale files.

Needs cards, dynamicCards.itemsPath, or both.

kpiCard — one big number
{
"type": "kpiCard",
"source": "main",
"valuePath": "kpis.total_transactions",
"format": "integer",
"color": "primary",
"sparklinePath": "sparklines.transactions", // optional array of numbers
"sparklineColor": "#0B6BCB",
"formulaKey": "edi.dashboard.kpi.total.formula", // tooltip
"onClick": { "kind": "navigate", "template": "/app/edi/transactions" }
}
keyValueList — two-column list
{
"type": "keyValueList",
"source": "main",
"basePath": "chosen_period",
"fallbackBasePath": "current_period", // used when basePath is empty
"keyWidth": 200,
"rows": [
{ "labelKey": "…", "valuePath": "period_no", "format": "text" },
{ "labelKey": "…", "valuePath": "start_date", "format": "date" }
]
}
progressList — labeled progress bars
{
"type": "progressList",
"source": "main",
"rows": [
{
"labelKey": "edi.dashboard.kb_incoming",
"valuePath": "metrics.total_incoming_transfers.value",
"percentagePath": "metrics.total_incoming_transfers.percentage",
"format": "bytes",
"color": "primary"
}
]
}

Charts

barChart, lineChart, pieChart and heatmap are sugar types — friendly, form-driven shapes that expand into an ECharts option at render time. Reach for these first; drop to raw chart only when they can't express what you need.

barChart / lineChart
{
"type": "barChart",
"source": "main",
"height": 400,
"categoriesPath": "amounts_by_partner[*].partner_name",
"xAxisNameKey": "edi.dashboard.partners",
"yAxisNameKey": "edi.dashboard.amount",
"yAxisFormatter": "currencyFormatter", // a $fn registry key
"orientation": "vertical", // "vertical" (default) | "horizontal"
"showLegend": true,
"stacked": true,
"series": [
{ "key": "not_paid", "nameKey": "edi.dashboard.qb_not_paid",
"dataPath": "amounts_by_partner[*].not_paid_amount", "color": "#c62828" },
{ "key": "fully_paid", "nameKey": "edi.dashboard.qb_fully_paid",
"dataPath": "amounts_by_partner[*].fully_paid_amount", "color": "#0e8f6e" }
]
}

Axis and series names are translation keys. A missing key falls back to itself, so plain text like "Not Paid" works — it just won't translate.

lineChart swaps stacked / orientation for smooth, area and step. Those three are also available per-series when one series needs to differ.

pieChart
{
"type": "pieChart",
"source": "main",
"namesPath": "status_summary[*].name",
"valuesPath": "status_summary[*].value",
"donut": true,
"showLegend": true,
"palette": ["#0e8f6e", "#c07c00"],
"onClick": {
"kind": "navigate",
"template": "/app/edi/invoices?status={dataIndex|$ref:main.status_summary[*].code}"
}
}
heatmap / calendarHeatmap

heatmap is a generic X×Y matrix (xCategoriesPath, yCategoriesPath, valuesPath). calendarHeatmap is a year of daily values:

{
"type": "calendarHeatmap",
"source": "main",
"valuesPath": "load_tenders_by_day", // [["2026-01-04T00:00:00", "12"], …]
"year": { "$today": "YYYY" },
"unitSuffix": "204",
"colorRange": ["#E8F2FB", "#0B6BCB"]
}
combinedBarPie — bars with a per-category pie breakdown
{
"type": "combinedBarPie",
"source": "main",
"height": 420,
"categoriesPath": "partners[*].name",
"xAxisNameKey": "edi.dashboard.partners",
"yAxisNameKey": "edi.dashboard.responses",
"series": [
{ "key": "accepted", "nameKey": "edi.dashboard.responses.accepted",
"dataPath": "partners[*].accepted", "color": "#7CC47F" },
{ "key": "rejected", "nameKey": "edi.dashboard.responses.rejected",
"dataPath": "partners[*].rejected", "color": "#E16060" }
],
"pieAggregation": "sum" // "sum" (default) | "last"
}
chart — the raw ECharts escape hatch

Full control, no guardrails. The usual reason to use it: per-bar colors, which the sugar types can't do (they color by series).

{
"type": "chart",
"source": "main",
"height": 360,
"echartsOption": {
"tooltip": { "trigger": "axis" },
"xAxis": { "type": "category", "data": ["In Transit", "Delivered"] },
"yAxis": { "type": "value", "axisLabel": { "formatter": { "$fn": "bytesFormatter" } } },
"series": [ { "type": "bar", "data": [
{ "value": { "$ref": "main.status_distribution.in_transit_count" },
"itemStyle": { "color": "#2576c8" } },
{ "value": { "$ref": "main.status_distribution.delivered_count" },
"itemStyle": { "color": "#0e8f6e" } }
] } ]
},
"toolbar": { "dataView": true, "expand": true, "saveAsImage": true, "zoom": true }
}

Bindings work anywhere inside echartsOption.

Data & maps

dataTable — sortable, paginated rows
{
"type": "dataTable",
"source": "main",
"rowsPath": "shipments",
"pageSize": 10,
"defaultSort": { "valuePath": "ship_date", "direction": "desc" },
"columns": [
{ "headerKey": "…", "valuePath": "tracking_number", "renderAs": "link",
"linkTemplate": "/app/edi/shipments/{shipment_id}", "width": 160 },
{ "headerKey": "…", "valuePath": "status", "renderAs": "badge", "color": "success" },
{ "headerKey": "…", "valuePath": "weight_lbs", "format": "decimal", "sortable": true }
],
"onRowClick": { "kind": "openModal", "modalId": "shipment_detail" }
}

renderAs: text | link | icon | badge. A link column needs a linkTemplate, whose {field} placeholders resolve against that row.

map — arcs, markers, choropleth

Built on react-simple-maps. Three independent layers, all optional. The per-row *Key fields are small accessors into a resolved row (a.b, value[0]) — not $ref paths.

{
"type": "map",
"source": "main",
"preset": "us-states", // us-states | canada | north-america
"height": 400,
"arcs": {
"path": "routes",
"fromKey": "from_state", "toKey": "to_state", "valueKey": "count"
},
"markers": {
"path": "map_points",
"lngKey": "value[0]", "latKey": "value[1]", "valueKey": "value[2]", "nameKey": "name"
},
"regionFill": {
"path": "by_state",
"regionKey": "state_name", "valueKey": "total",
"colorRange": ["#dce8f2", "#0B6BCB"]
},
"tooltipSuffix": "shipments",
"onClick": { "kind": "openModal", "modalId": "shipment_list" }
}

Layout

group — a vertical stack in one grid cell
{
"type": "group",
"grid": { "xs": 12 },
"children": [ { /* widget */ }, { /* widget */ } ]
}

It's a nested grid, so children honor their own spans.

tabbedGroup — an in-card tab switcher
{
"type": "tabbedGroup",
"grid": { "xs": 12, "lg": 6 },
"labelKey": "edi.dashboard.data_transfer_heatmap",
"tabs": [
{ "key": "total", "labelKey": "…", "widget": { /* full widget */ } },
{ "key": "incoming", "labelKey": "…", "widget": { /* full widget */ } }
]
}

This is how you build a toggle between two views. Hardware Resources' flow-map / carrier-mix switch is a tabbedGroup of two map widgets, not a bespoke control.


9. Bindings

Marker objects can appear anywhere a dynamic value is accepted, including deep inside an echartsOption. They're resolved after data arrives and before the option reaches ECharts.

MarkerUsed for
{ "$ref": "<source>.<path>" }pull data out of a data source
{ "$tr": "<translationKey>" }a translated string
{ "$fn": "<registryKey>", "args"?: {} }a named formatter
{ "$filter": "<filterKey>" }filter value — only in DataSource.params
{ "$today": "<format>" }today's date
{ "$company": true }current company id

The $fn registry

The only executable code a config can reach:

KeyUsed for
numberFormatterinteger axis labels / tooltips
currencyFormattercurrency axis labels / tooltips
percentFormatterpercent axis labels / tooltips
bytesFormatterKB / MB / GB axis labels
compactNumberFormatter1.2K, 3.4M
stackedTotalsTooltipFormatterstacked-bar tooltip with a grand total
currencyPieTooltipFormatterpie tooltip, currency + percent
numberPieTooltipFormatterpie tooltip, number + percent
dateLabelFormatterdate axis labels (args.style)
shortDateFormatterMM/DD for dense time series

Adding one means editing registry.tsx and widget_catalog.json. Miss the second and the Validator warns about an unknown function.

onClick

Widget clicks support exactly two kinds:

KindShape
navigate{ "kind": "navigate", "template": "/app/…" }
openModal{ "kind": "openModal", "modalId": "shipment_list" }

openModal targets a modal registered host-side through DashboardModalHost. Drilldowns that need real code — a tracking timeline, say — live there by design and are referenced by id.

Don't confuse these with action kinds

apiCall, download, exportCsv, applyFilter and link are toolbar buttons (§6). They are not available on a widget click.

Navigate templates can carry placeholders:

PlaceholderResolves to
{name} / {value} / {dataIndex}the matching ECharts click param
{dataIndex|$ref:<path>}path resolved to an array, indexed by dataIndex

If a placeholder can't resolve, navigation is aborted rather than sending the user to a broken URL.


10. Formats, colors, icons

Formats — for statCards, keyValueList, kpiCard, progressList and dataTable columns:

FormatOutput
textraw string
integer1,234
decimal1,234.56
currencyCA$ 1,234.56
percent12.3% (input 0–100)
bytes1.2 MB (input in KB)
duration2h 15m (input in minutes)
date / datetimelocale date, with or without time

Colors — five semantic tokens: primary (brand blue #0B6BCB), neutral, success, danger, warning. Chart colors also accept hex or an ECharts gradient object.

IconsiconName comes from CustomIcon.tsx, mirrored server-side in Catalog::ICON_NAMES and served to the Builder's picker. An unknown name renders a neutral placeholder rather than breaking the card.

Translations — every user-facing string is a key. Missing keys fall back to themselves, so the UI never blanks out. New keys: dashboard.<app_mode>.<tab_key>.<…>. Legacy keys still work.


11. Permissions

Three independent stops:

Config filtering is defense in depth, never the only gate — the endpoints check independently.

Permission codes are also how you show different users different things inside one config. Two active configs for the same (company, app mode) are not supported. If Mitgo needs dispatch and billing to see different tabs, that's two tabs with different requiredPermissions in a single config.

A code in no permission group grants nobody anything

Creating the code is half the job. It has to be attached to a group, and users have to be in that group. This is the most common reason a finished dashboard shows up empty.


13. One list, three consumers

api/dashboard_configs/widget_catalog.json holds the enumerated lists: widget types, container types, $fn keys, filter types, action kinds, format kinds, colors, inherit modes, app modes.

The TS mirror is typed as a total Record<LeafWidgetType, FC<…>>, so adding a widget type without a renderer is a compile error, not a runtime surprise.

Adding a widget type is four edits, and the compiler catches a miss:

  1. widget_catalog.json
  2. web/app/types/dashboardConfig.ts
  3. widgets/catalog.tsx (the renderer)
  4. builder/forms/ + WIDGET_FORMS — optional; skip it and the Inspector falls back to the advanced JSON editor

15. API reference

MethodPathPurpose
GET/dashboard_configs/currentThe resolved, filtered config. The only endpoint end users hit.
GET/dashboard_configsList. Filters: app_mode, company_id, is_active.
GET/dashboard_configs/:idOne row, with config.
POST/dashboard_configsCreate. An active create deactivates siblings first.
PATCH/dashboard_configs/:idInserts a new revision — never mutates in place.
DELETE/dashboard_configs/:idSoft delete. X-Confirm-Hard-Delete: true destroys that one row.
POST/dashboard_configs/:id/activateActivate, deactivating siblings atomically.
POST/dashboard_configs/:id/deactivateCompany configs only — the global default can't be deactivated.
POST/dashboard_configs/:id/duplicateClone into a draft.
POST/dashboard_configs/:id/restoreReactivate an old revision as a new active row.
POST/dashboard_configs/validateValidate without saving.
GET/dashboard_configs/defaults/:app_modeThe global default, for the inheritance picker.
GET/dashboard_configs/catalogIcons, colors, formats, widget types, $fn keys, kinds, permissions.

Everything except current requires a system-record user.

The revision pattern

PATCH doesn't update — it deactivates and inserts, in one transaction:

Order matters. Deactivating first is what keeps the insert from colliding with the filtered-unique index enforcing "one active row per scope."

Old revisions stay queryable forever. That's the audit trail, and it's what "Restore" reads.