# Plugin Dashboard





Dashboard layouts and metric widgets for creating beautiful dashboards with KPIs, charts, and statistics.

## Installation [#installation]

```bash
npm install @object-ui/plugin-dashboard
```

<PluginLoader plugins="['dashboard']">
  ## Interactive Examples [#interactive-examples]

  <SchemaExample id="plugin-dashboard/basic-dashboard" />

  <SchemaExample id="plugin-dashboard/e-commerce-dashboard" />

  <SchemaExample id="plugin-dashboard/support-dashboard" />

  ## Features [#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 [#schema-api]

  ### Dashboard [#dashboard]

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

  ### Metric Card [#metric-card]

  ```plaintext
  {
    type: 'metric-card',
    title: string,
    value: string | number,
    icon?: string,                  // Lucide icon name
    trend?: 'up' | 'down' | 'neutral',
    trendValue?: string,
    description?: string,
    className?: string
  }
  ```

  ## Usage [#usage]

  ### Auto-registration (Side-effect Import) [#auto-registration-side-effect-import]

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

  ### What the side-effect import registers [#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 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`.

  ### Registering a component under your own key [#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:

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

  ## Examples [#examples]

  ### Dashboard with Multiple Metrics [#dashboard-with-multiple-metrics]

  ```json
  {
    "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 [#dashboard-with-charts]

  ```json
  {
    "type": "dashboard",
    "widgets": [
      {
        "type": "metric-card",
        "title": "Total Revenue",
        "value": "$123,456"
      },
      {
        "type": "card",
        "title": "Sales Trend",
        "body": {
          "type": "line-chart",
          "data": [],
          "height": 300
        }
      }
    ]
  }
  ```

  ## Dashboard-level filters [#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`).

  ```json
  {
    "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&#x60; shape was removed: the
  renderer no longer reads those keys and shows a &#x2A;"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:

  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`. 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](/docs/guide/dashboard-filters). 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 [#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`                                              |
  | `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"]`.

  ### Gauge / solid-gauge / kpi / bullet render as a metric card [#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.

  ## TypeScript Support [#typescript-support]

  ```plaintext
  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-aware list/table widget cells [#type-aware-listtable-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](https://github.com/objectstack-ai/objectui/blob/main/packages/plugin-dashboard/README.md#type-aware-listtable-widget-cells)
  for the full mapping table and an example.

  ## License [#license]

  MIT
</PluginLoader>
