Skip to content

Latest commit

 

History

History
634 lines (540 loc) · 27.5 KB

plugin-dashboard.mdx

File metadata and controls

634 lines (540 loc) · 27.5 KB
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.

Installation

npm install @object-ui/plugin-dashboard

<PluginLoader plugins={['dashboard']}>

Interactive Examples

Features

  • 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

Schema API

Dashboard

{
  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.

Metric Card

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.

Usage

Auto-registration (Side-effect Import)

import '@object-ui/plugin-dashboard';

What the side-effect import registers

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.

Registering a component under your own key

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).

Examples

Dashboard with Multiple Metrics

{
  "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%"
    }
  ]
}

Dashboard with Charts

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.

This snippet used to teach `"type": "line-chart"`, a key registered as a lazy stub the charts plugin never fulfilled, so a document copied from here sat on `Loading line-chart…` forever (objectui#8760; the key is retired). It then taught a `card` widget wrapping a `chart` node in `children`, which `@object-ui/types/zod`'s `DashboardComponentSchema` refuses on the tolerant face and the strict one (objectui#11484). The widget below is the shape the package README teaches.
{
  "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"]
    }
  ]
}

Dashboard-level filters

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:

  1. filterBindings[name] as a string — apply the filter to that field.
  2. filterBindings[name]: false — opt this widget out.
  3. Legacy targetWidgets on the filter — when set, only listed widget ids get the default binding (an explicit filterBindings entry still wins).
  4. Otherwise the filter applies to its own field (the built-in date range defaults to dateRange.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).

Widget options — what is actually read

@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"].

Gauge / solid-gauge / kpi / bullet render as a metric card

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.format is not read — declare the format on the dataset measure instead;
  • options.thresholds is not read — the census header in packages/sdui-parser/src/dashboard-widget-options.ts is the canonical statement of that closure claim, and a gate re-derives it on every test run;
  • options.invert is 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 with no type, or a type that names no family

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.

How many measures a widget renders

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/spec refuses a second one at its door; several numbers is a different visual.
  • A widget with no dimensions and several measures renders every one of them when its type is table / pivot (one row of measures) or a chart of the bar family (bar, column, horizontal-bar) or line / area / combo (one mark per measure, the measures' labels on the category axis, which runs down the side on a horizontal-bar).
  • Any other dimensionless widget with several measures still renders the first one as a tile.
  • A pie, donut, funnel, treemap or sankey with 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.

Drilling into a report

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, objectName plus column objects (objectui#11506). Write dataset where it had objectName, 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.

Translations — a served dashboard is translated once

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).

TypeScript Support

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-aware list/table widget cells

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.

License

MIT