| title | Plugin Dashboard |
|---|
import { InteractiveDemo } from '@/app/components/InteractiveDemo'; import { PluginLoader } from '@/app/components/PluginLoader';
Dashboard layouts and metric widgets for creating beautiful dashboards with KPIs, charts, and statistics.
npm install @object-ui/plugin-dashboard<PluginLoader plugins={['dashboard']}>
- Dashboard Layouts - Responsive grid-based layouts
- Metric Cards - Display KPIs with trends and icons
- Widget System - Modular widget architecture
- Customizable - Full Tailwind CSS styling support
{
type: 'dashboard',
widgets: Widget[],
label?: string | LocaleMap, // Header title — spec-canonical spelling; a string or { en, "zh-CN", ... }
description?: string | LocaleMap, // Header description, under the title
header?: { // Header block — strict: exactly these keys
showTitle?: boolean,
showDescription?: boolean,
actions?: {
label: string | LocaleMap, // button text; a map resolves to the active language
actionUrl: string, // required: the URL, or the target the action runner dispatches
actionType?: 'url' | 'script' | 'modal' | 'flow' | 'api' | 'form',
icon?: string // Lucide icon name
}[]
},
globalFilters?: GlobalFilter[], // Dashboard-level filter bar — see "Dashboard-level filters"
dateRange?: { // Built-in date-range filter — see "Dashboard-level filters"
field?: string,
defaultRange?: string, // a spec date preset, or 'custom'
allowCustomRange?: boolean
},
refreshIntervalSeconds?: number, // Auto-refresh period in seconds; runs only when the host wires onRefresh
columns?: number, // Grid columns (default: 3)
gap?: number, // Gap between widgets
className?: string
}
The header renders only when header is declared, and costs zero pixels when
everything it would show is suppressed. The legacy title spelling of label
is still read (documents in the wild carry it) but is not authoring surface —
the spec rejects it by name, so new documents author label. The retired
aria key is neither read nor authorable.
A metric-card is a dashboard widget-slot entry: it sits directly in a
dashboard's widgets[] (the closed component slot of the 2026-08-14 ruling),
and the dashboard renders it as the plugin-dashboard:metric-card node. The
bare metric-card NODE key is retired (objectui#10859), so a metric-card
outside a dashboard's widgets[] renders nothing but the renderer's
"Unknown component type" panel. Its keys, as DashboardWidgetSlotComponentSchema
(@object-ui/types) declares them and @object-ui/types/zod judges them:
{
type: 'metric-card',
title?: string | I18nLabel, // plain string or inline per-locale map
value: string | number, // required
icon?: string, // Lucide icon name
trend?: 'up' | 'down' | 'neutral', // drawn with trendValue
trendValue?: string, // drawn with trend
description?: string | I18nLabel,
className?: string
}
A card with no value is refused by @object-ui/types/zod, on the tolerant
face and the strict one. metric-card is not a widget type, so a card is never
read through widget keys, and no widget key stands in for value: the
dashboard reads a widget key (dataset, options, component,
colorVariant, …) on a widget alone, so a card draws its own keys whatever
else it carries, and the strict face refuses a widget key on it
(objectui#11483, objectui#11598). For a figure queried from a
dataset, write a metric widget with dataset and values.
import '@object-ui/plugin-dashboard';That single import is the whole of registration — there is no components map to
iterate over. Importing the entry runs the seven live ComponentRegistry.register(...)
calls in packages/plugin-dashboard/src/index.tsx, which claim exactly these
schema types. The keys below are read off those calls.
| Namespaced key | Bare-name fallback | Renderer behind it |
|---|---|---|
plugin-dashboard:dashboard |
dashboard |
DashboardRenderer — the widget container |
plugin-dashboard:metric |
none — skipFallback: true |
MetricWidget — one KPI value |
plugin-dashboard:metric-card |
none — skipFallback: true |
MetricCard — KPI with trend and icon |
plugin-dashboard:object-metric |
object-metric |
internal wrapper around ObjectMetricWidget — a metric aggregated over an object |
plugin-dashboard:pivot |
pivot |
PivotTable — pivot over rows you pass in |
plugin-dashboard:object-pivot |
object-pivot |
ObjectPivotTable — a pivot queried from an object; its props go in properties |
plugin-dashboard:object-data-table |
object-data-table |
ObjectDataTable — table queried from an object |
ComponentRegistry.register publishes namespace:type, and — unless the call
passes skipFallback: true — the bare type as a back-compat fallback
(packages/core/src/registry/Registry.ts:194, fallback branch at :226). Every
call behind the table above leaves skipFallback unset except metric and
metric-card, so each other type there resolves under both spellings; the schema
snippets on this page use the bare one.
metric and metric-card are two vocabularies that share a spelling: the
dashboard WIDGET types (widgets[].type: 'metric', and the metric-card slot
entry above) and the internal NODE keys the dashboard renders them through. The
widget types are unchanged. The node keys register with skipFallback: true
(objectui#10859 batch 8), and both dashboard surfaces emit the namespaced keys, so
only plugin-dashboard:metric / plugin-dashboard:metric-card resolve as nodes.
dashboard-grid is no longer registered (objectui#10859 batch 8: builder chrome,
with no producer and no runtime emitter). DashboardGridLayout is still exported;
a host that wants the drag/resize grid mounts the component directly.
⛔ One further registration is deliberately absent from the table above. Until
objectui#9533 the dashboard renderer was published as view:dashboard, while
apps/console's lazy stubs and the CLI's known-type whitelist already spelled it
plugin-dashboard:dashboard; the bare dashboard key therefore declared one
namespace before the chunk loaded and the other after it, and the
plugin-dashboard:dashboard stub was never cleared, so that spelling could never
resolve. The renderer now registers under plugin-dashboard, and the retired
view:dashboard key is answered by a tombstone widget that refuses BY NAME and
names plugin-dashboard:dashboard as its replacement — so an authored
view:dashboard gets a visible refusal carrying its own migration, never a
silent fall-through. That tombstone registration passes skipFallback: true, so
it claims no bare key, and view:dashboard is deliberately NOT a renderable key:
objectui check reports it as unknown.
The two object-* rows name a wrapper rather than an export on purpose.
ObjectMetricBlock and ObjectPivotBlock are internal to src/index.tsx: each
first resolves the element's dataSource binding through ElementDataSourceGate
(from @object-ui/react) and only then renders the exported
ObjectMetricWidget / ObjectPivotTable.
The import above needs no follow-up call. What a manual registration is for here is serving one of this package's exported components under a key of your own — you register the component, not a map:
import { ComponentRegistry } from '@object-ui/core';
import { MetricCard } from '@object-ui/plugin-dashboard';
ComponentRegistry.register('my-metric', MetricCard, {
namespace: 'my-app',
label: 'My Metric',
category: 'Dashboard',
});The package also exports a dashboardComponents object: the manual-integration
map, keyed by the same seven schema types as the table above (objectui#5064
re-keyed it from component class names; since objectui#10859 batch 8 the metric
and metric-card entries are keyed plugin-dashboard:metric /
plugin-dashboard:metric-card, the type each serves, so iterating the map cannot
re-create their retired bare keys). Each key maps to the exact component
the import registers for that type — for the two object-* types that is the
internal data-source-gate wrapper, not the exported widget. Iterating it with
ComponentRegistry.register(type, component) therefore re-registers the seven
types the import has already claimed, which is still not the registration above:
each such call passes no meta, so it trips the no-namespace deprecation
warning in register (packages/core/src/registry/Registry.ts:198) and
rewrites each bare-name registry entry without its label/category metadata
(the namespace:type entries are untouched).
{
"type": "dashboard",
"columns": 4,
"gap": 6,
"widgets": [
{
"type": "metric-card",
"title": "Total Sales",
"value": "$123,456",
"icon": "shopping-cart",
"trend": "up",
"trendValue": "+15%"
},
{
"type": "metric-card",
"title": "New Customers",
"value": "856",
"icon": "user-plus",
"trend": "up",
"trendValue": "+22%"
},
{
"type": "metric-card",
"title": "Bounce Rate",
"value": "2.4%",
"icon": "trending-down",
"trend": "down",
"trendValue": "-5%"
},
{
"type": "metric-card",
"title": "Avg. Order Value",
"value": "$144.20",
"icon": "dollar-sign",
"trend": "up",
"trendValue": "+8%"
}
]
}A chart on a dashboard is a widget, and the widget names its chart family in
type — one of the spec's chart families (line, area, bar, pie,
donut and the rest of the closed vocabulary DashboardWidgetTypeName
declares). It binds a dataset (ADR-0021), selecting the dimension it plots in
dimensions and its measures in values; it carries no rows. There is no
card widget family, and a widget has no children slot to nest a node in.
{
"type": "dashboard",
"widgets": [
{
"type": "metric-card",
"title": "Total Revenue",
"value": "$123,456"
},
{
"id": "sales_trend",
"type": "line",
"title": "Sales Trend",
"dataset": "sales",
"dimensions": ["month"],
"values": ["revenue"]
}
]
}A dashboard can declare top-level filters — a date range plus any number of
select / text filters — whose values drive every bound widget at once. Filter
values live as dashboard-level variables; each widget declares which of its
own fields a filter binds to via filterBindings, and the dashboard merges
the active values into each bound widget's inline query (AND-combined with
the widget's own filter).
{
"type": "dashboard",
"dateRange": { "field": "created_at", "defaultRange": "last_30_days", "allowCustomRange": true },
"globalFilters": [
{
"name": "region", "field": "region", "label": "Region", "type": "select",
"options": [
{ "value": "EMEA", "label": "EMEA" },
{ "value": "APAC", "label": "APAC" },
{ "value": "AMER", "label": "AMER" }
]
}
],
"widgets": [
{ "id": "w1", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] },
{
"id": "w2", "type": "line", "dataset": "accounts", "dimensions": ["signed_month"], "values": ["count"],
"filterBindings": { "dateRange": "signed_at", "region": "sales_region" }
},
{
"id": "w3", "type": "metric", "dataset": "invoices", "values": ["count"],
"filterBindings": { "region": false }
}
]
}The widgets above bind a dataset (ADR-0021). The pre-ADR-0021 top-level
object + categoryField / valueField / aggregate shape was removed: the
renderer no longer reads those keys and shows a "This widget uses a retired
data format. Edit it to bind a dataset." prompt instead of a chart. Inline
widget data is not an authoring surface either: options.data (an array of
rows or a { "provider": "object", … } query) and options.xField /
options.yField are refused by name by @object-ui/types' strict authoring
face (objectui#11228), and every widget binds a dataset. A stored single-value
widget (metric, gauge, solid-gauge, kpi, bullet, or one with no
type) whose options.data is a { "provider": "object", … } query shows the
same retired-format prompt instead of its number (objectui#11525), as a pivot
widget with that query already did.
Binding rules, in precedence order:
filterBindings[name]as a string — apply the filter to that field.filterBindings[name]: false— opt this widget out.- Legacy
targetWidgetson the filter — when set, only listed widget ids get the default binding (an explicitfilterBindingsentry still wins). - Otherwise the filter applies to its own
field(the built-in date range defaults todateRange.field ?? 'created_at').
Date presets stay symbolic (date-macro tokens such as {30_days_ago}) until
query time. Dataset-bound widgets receive the merged filter through the
dataset query's runtimeFilter. Filter values are also readable in widget
expressions as page.<name>.
For a step-by-step tutorial — filter types, optionsFrom dynamic options,
page.* expression usage, and known limitations with workarounds — see the
Dashboard-Level Filters guide. The schema
catalog ships runnable variants under plugin-dashboard/filtered-dashboard*
(dynamic options, filter types, dataset widgets, targetWidgets, date
presets).
@objectstack/spec's DashboardWidgetOptionsSchema declares five keys and
then rides .passthrough() ("declared query keys + open renderer extras"), so
any other key parses and validates cleanly — whether or not anything reads
it. On a dataset-bound widget (the only spec-legal form: dataset is
required), the renderers read exactly:
| Key | Effect |
|---|---|
dateGranularity |
groups a date dimension by day / week / month / … |
sortBy / sortOrder |
orders by a projected dimension or measure |
limit |
caps the row count |
stageOrder |
explicit stage order for funnel / pyramid |
These are the five keys the spec declares, and nothing else is read.
options.description is not among them: it was the metric-card sub-caption,
retired at both ends (objectui#11389). @objectstack/spec no longer writes
it from a translation, and objectui no longer draws it. A widget has one
authored description, widget.description, drawn as the card-header subtitle.
If a caption under a metric's value returns, it returns as a declared
widget-level key, not as an options key.
Every other options key on a dataset-bound widget reaches no renderer.
The authoring validator (validateTree in @object-ui/sdui-parser) reports
each one as a unconsumed-widget-option warning naming the set above —
the document still parses, saves and renders; open extras stay open, they just
stop being silent. A widget with a genuine out-of-band consumer can opt out in
metadata with the spec's own escape hatch:
"suppressWarnings": ["unconsumed-widget-option"].
There is no dial/arc/target renderer: the single-value families are routed by
construction to the metric-card path (objectui#4295) and render as one
formatted number. The number's format — including percent handling and
currency — comes from the dataset measure's own metadata (the measure's
format / currency / percentScale returned by the dataset query), not
from widget options. In particular, on a dataset-bound gauge:
options.formatis not read — declare the format on the dataset measure instead;options.thresholdsis not read — the census header inpackages/sdui-parser/src/dashboard-widget-options.tsis the canonical statement of that closure claim, and a gate re-derives it on every test run;options.invertis not read — if a measure needs to be displayed as its complement (e.g. a compliance rate stored as a violation rate), add a derived measure to the dataset (derived: { op: 'ratio', … }) and bind the widget to it.
A widget that declares no type is a metric widget. @objectstack/spec's
DashboardWidget.type defaults to metric, and both dashboard surfaces
(DashboardRenderer and DashboardGridLayout) read that default from the spec,
so the widget draws exactly as the same widget with type: 'metric' does,
inline or bound to a dataset (objectui#11514). objectui's validator accepts the
widget without a type and does not write the default in, so the surfaces are
where it resolves.
objectui's legacy component envelope ({ id, component, layout }) is not the
spec's widget, and the default is not applied to it: an envelope with no type
draws its component under its card heading.
The envelope takes a metric-card node only (objectui#12163), and draws one
only (objectui#12254). Both validator faces and
DashboardWidgetSchema['component'] refuse any other node by name, at
component, with the fix: a dashboard widget binds a dataset (ADR-0021), so
write the widget itself in the spec form, or put a metric-card in the slot.
Both surfaces draw a stored envelope by the same rule: a metric-card draws as
written, and any other node (an object-chart, a text, an object-metric, a
node with no type) draws the same retired-format prompt as a dataset-less
metric widget and sends no query. A custom widget with no component draws
the labelled placeholder described next.
A type that names no widget family and no component type (a typo, or a family
the spec no longer has) is refused by both validator faces at type. A stored
one draws the labelled placeholder "「type」chart type is not supported yet", as
a known family with no renderer (heatmap) does, instead of the renderer's red
"Unknown component type" panel.
A dataset-bound widget queries every measure in values. What it renders
depends on its shape:
- A metric-family widget (
metric,kpi,gauge,solid-gauge,bullet) is a one-number tile. It takes exactly one measure, and@objectstack/specrefuses a second one at its door; several numbers is a different visual. - A widget with no
dimensionsand several measures renders every one of them when its type istable/pivot(one row of measures) or a chart of the bar family (bar,column,horizontal-bar) orline/area/combo(one mark per measure, the measures' labels on the category axis, which runs down the side on ahorizontal-bar). - Any other dimensionless widget with several measures still renders the first one as a tile.
- A
pie,donut,funnel,treemaporsankeywith a dimension draws one series, the first measure, however many it declares: the shared chart renderer reads the first series only on those families.
When a widget keeps a declared measure off the screen, DatasetWidget logs
one console warning naming the widget, the measures it renders, and the ones it
queried and never displayed. Three shapes do that. Two take the tile: a metric
tile stored before the spec narrowed, and a widget the third bullet above
covers (no dimensions, several measures, and a type the second bullet does not
list, so a dimensionless pie is one). The third is a pie, donut,
funnel, treemap or sankey with a dimension and several measures
(objectui#11417). For a tile the warning
then names the spec's ADR-0087 entry
dashboard-widget-metric-family-multi-measure-refused, whose replacement
says what to author instead of a one-number tile with several measures. For
one of those five charts it says that the chart family draws a single series,
the first declared measure, and points to no entry: that one answers for a
tile. Nothing about what is drawn changes. Every door accepts the chart shape
today; the spec is to refuse it (objectstack#21293).
The widget panel (WidgetConfigPanel) asks the same door the spec runs: it
offers no further measure the door would refuse, and shows the door's own
message under measures it already refuses.
The behaviour is pinned by packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx,
packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx and
packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx.
An object-pivot or object-metric block whose drillDown carries a report
opens that report in the drill drawer instead of the record list. The report is
dataset-bound, @objectstack/spec's ReportSchema: the same document a
report node draws.
{
"type": "object-pivot",
"properties": {
"objectName": "deal",
"rowField": "stage",
"columnField": "owner",
"valueField": "amount",
"aggregation": "sum",
"drillDown": {
"enabled": true,
"report": {
"name": "deals_by_stage",
"label": "Deals by Stage",
"dataset": "deals_ds",
"rows": ["stage"],
"values": ["amount_sum"]
}
}
}
}The drawer scopes the report by what was clicked: it joins the block's resolved
drill filter to the report's own runtimeFilter with $and and writes the
result as the report's runtimeFilter, the one filter key the dataset renderer
applies. Don't author filter on the report: the spec refuses it and the
renderer does not apply it.
Two other drill-report forms are retired, with no alias window. The TypeScript
declaration refuses both, and so do both validators, each naming the
retirement: the tolerant face (safeValidateSchema) and the strict authoring
face (StrictAnyComponentSchema, which objectui validate runs).
- The pre-9.0 object-bound report,
objectNameplus column objects (objectui#11506). Writedatasetwhere it hadobjectName, and dimension names where it had column objects. - A named reference,
{ "name": "…" }(objectui#11517). Nothing resolves a report name, so such a drill listed the records. Write the report inline, as above.
A stored value that never went through a validator still reaches the drawer, which lists the records for either form and draws no report.
Widget texts have a client translation channel: bundle keys under
dashboards.<name>.widgets.<id> (title, description), plus
dashboards.<name>.label and .description. There is no subCaption key:
the translation schema in @objectstack/spec refuses it by name in a bundle,
and the renderer reads no such key (objectui#11389). A document read from
ObjectStack's /meta route does not need it. The server has already applied
those keys for the request's locale, and it keeps a published edit over them:
an explicit override beats the packaged default.
So the host tells the renderer which kind of document it holds. The localized
prop on DashboardRenderer says the document was served, and the console's
dashboard page sets it. Those texts are then drawn as served. Without the prop,
the client bundle translates them. That covers an inline dashboard block, a
preview or a design surface (objectui#11295).
import type {
DashboardComponentSchema,
DashboardWidgetSchema,
DashboardWidgetSlotComponentSchema,
} from '@object-ui/types';
const widget: DashboardWidgetSchema = {
id: 'revenue',
title: 'Revenue',
type: 'metric',
dataset: 'invoices',
values: ['amount'],
layout: { x: 0, y: 0, w: 1, h: 1 },
};
const card: DashboardWidgetSlotComponentSchema = {
id: 'users',
type: 'metric-card',
title: 'Users',
value: '1,234',
layout: { x: 1, y: 0, w: 1, h: 1 },
};
const dashboard: DashboardComponentSchema = {
type: 'dashboard',
columns: 3,
widgets: [widget, card],
};An entry of widgets[] is either a widget (DashboardWidgetSchema) or a
component node placed directly in the slot (DashboardWidgetSlotComponentSchema).
The component arm declares the card's registered inputs — the keys above. The
widget keys (colorVariant, filter, dataset, …) are declared on the widget
arm, which takes them from the spec's DashboardWidget row, and on no member of
the component arm. An entry's own type is the slot's element type,
DashboardComponentSchema['widgets'][number]. The component arm is not
assignable to DashboardWidgetSchema, whose type names no component type, so
narrow an entry on type before reading a widget key. metric-card is the one
component type the slot holds; any other entry is the widget arm, the compiler
checks the narrowing, and the key gets its declared type instead of the any
the component arm's passthrough supplies:
import type { DashboardComponentSchema } from '@object-ui/types';
declare const dashboard: DashboardComponentSchema;
const accents = dashboard.widgets.map((w) =>
w.type === 'metric-card' ? 'default' : (w.colorVariant ?? 'default'),
);title and layout are the exceptions: both arms declare them. title is the
card's heading on the component arm, typed as MetricCard reads it. The
component arm declares layout by reference to the spec's widget layout,
because the editable grid's Save Layout writes it onto every entry, a
metric-card node included. layout reads with the spec's type off any
widgets[] entry, the strict authoring face accepts it on a component
node, and a malformed one (not four numbers, or a key besides x, y, w and
h) is refused on either arm.
A widget with no layout is auto-placed by the grid. The width and height
editors (Studio's widget inspector, DashboardWithConfig and
DashboardEditor) write the whole box: the coordinates an edit leaves alone
come from where the grid auto-places that widget, through completeWidgetLayout
and defaultWidgetPlacement from @object-ui/types. So changing one dimension
never stores a { w } or { h } box, which the spec refuses.
type: 'table' widgets bound to an objectName infer the renderer for
each cell from the object's field type — Badges for select, expanded
display name for lookup/user/owner, formatted currency/percent/date
for numeric fields, and so on. Lookup columns are auto-expanded server-side
via $expand, so you don't see raw FK ids. See
@object-ui/plugin-dashboard README
for the full mapping table and an example.
MIT