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 NotFound → 404.
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.
DataSourcesContextfires each data source once and caches it. Ten widgets reading"main"cause one network call.useFilterStatesyncs filters to the URL, so a filtered dashboard is shareable.bindings.tsresolves every marker before anything reaches ECharts.WidgetShellgives 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 */ ]
}
| Field | Notes |
|---|---|
version | Must be 2. |
name | Optional. Shown in the Builder list, never to end users. |
inheritDefaultTabs | "prepend" puts the default's tabs before yours, "append" after. Deduped by key. |
tabs | Required, 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:
| Path | Resolves to |
|---|---|
main | the whole payload |
main.cards.employees_count | nested object access |
main.years_period[0].net_salary | one array element |
main.years_period[*].net_salary | map 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
| Marker | Resolves 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" }
}
type | UI |
|---|---|
select | single dropdown |
multiSelect | multi-value, comma-separated in the URL |
groupedSelect | dropdown with groupKey headers |
dateRange | two URL params, <key>_start and <key>_end |
date | single date |
text | free text |
number | numeric 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:
kind | Does | Needs |
|---|---|---|
link | client-side navigation | target |
apiCall | fires method + target; honors confirm; toasts | target |
download | same, expects a blob | target |
exportCsv | builds a CSV in the browser, or downloads from target | csv or target |
openModal | opens a host-registered modal | modalId |
applyFilter | sets a filter value | filterKey |
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" }
]
}
]
}