| 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, actionUrl?, actionType?, icon? }[]
},
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
},
refreshInterval?: 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.
{
type: 'metric-card',
title: string,
value: string | number,
icon?: string, // Lucide icon name
trend?: 'up' | 'down' | 'neutral',
trendValue?: string,
description?: string,
className?: string
}
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 eight 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 |
|---|---|---|
view:dashboard |
dashboard |
DashboardRenderer — the widget container |
plugin-dashboard:metric |
metric |
MetricWidget — one KPI value |
plugin-dashboard:metric-card |
metric-card |
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 |
internal wrapper around ObjectPivotTable — pivot queried from an object |
plugin-dashboard:dashboard-grid |
dashboard-grid |
DashboardGridLayout — the drag/resize editable grid |
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). No
call in this package passes skipFallback, so each type above resolves under
both spellings; the schema snippets on this page use the bare one.
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 eight schema types as the table above (objectui#5064
re-keyed it from component class names). 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 eight
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%"
}
]
}{
"type": "dashboard",
"widgets": [
{
"type": "metric-card",
"title": "Total Revenue",
"value": "$123,456"
},
{
"type": "card",
"title": "Sales Trend",
"body": {
"type": "line-chart",
"data": [],
"height": 300
}
}
]
}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. A widget
that needs a renderer-internal query rather than a semantic-layer one puts it
under options.data as { "provider": "object", "object": "invoices", "aggregate": { "function": "count", "groupBy": "status" } }; an
options.data array is fixed demo data and is not filtered.
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. Static-data widgets (inline data arrays)
have no query to scope and are not filtered. 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 |
description |
metric-card sub-caption; the widgets.{id}.subCaption translation channel writes this 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.
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
const widget: DashboardWidgetSchema = {
id: 'revenue',
title: 'Revenue',
type: 'metric-card',
layout: { x: 0, y: 0, w: 1, h: 1 },
};
const dashboard: DashboardComponentSchema = {
type: 'dashboard',
columns: 3,
widgets: [widget],
};
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