ObjectOS
ConstruireInterface

Dashboards

Analytics pages built from chart widgets bound to named datasets — with global filters, auto-refresh, and drill-through to the underlying records.

Dashboards

A dashboard is a grid of widgets; every widget binds to a dataset. The dataset is the semantic layer: it owns the base object, joins, dimensions, and certified measures. Widgets select those by name, so "revenue" means the same thing on every dashboard and report that uses it.

const salesDashboard = {
  name: 'sales_overview',
  label: 'Sales Overview',
  description: 'Key sales metrics and pipeline analysis',
  refreshInterval: 300,                   // auto-refresh every 5 minutes

  dateRange: {
    field: 'close_date',
    defaultRange: 'this_quarter',
    allowCustomRange: true,
  },

  widgets: [
    { id: 'total_revenue',     title: 'Total Revenue',     type: 'metric',
      dataset: 'sales', values: ['revenue'],
      layout: { x: 0, y: 0, w: 3, h: 2 } },
    { id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar',
      dataset: 'sales', dimensions: ['region'], values: ['revenue'],
      layout: { x: 3, y: 0, w: 6, h: 4 } },
    { id: 'deals_by_month',    title: 'Deals by Month',    type: 'pie',
      dataset: 'sales', dimensions: ['close_month'], values: ['deal_count'],
      layout: { x: 9, y: 0, w: 3, h: 4 } },
  ],
}

Dashboard properties

PropertyTypeRequiredDescription
namestringyesMachine name (snake_case)
labelstringyesDisplay label
descriptionstringDashboard description
widgetsDashboardWidget[]yesChart and metric widgets
refreshIntervalnumberAuto-refresh interval (seconds)
dateRangeobjectGlobal date range filter
globalFiltersGlobalFilter[]Interactive filter controls

Datasets first

Define the dataset once; bind every widget to it:

import { defineDataset } from '@objectstack/spec/ui'

export const salesDataset = defineDataset({
  name: 'sales',
  label: 'Sales',
  object: 'opportunity',
  include: ['account'],
  dimensions: [
    { name: 'region',      field: 'account.region', type: 'string' },
    { name: 'close_month', field: 'close_date',     type: 'date', dateGranularity: 'month' },
  ],
  measures: [
    { name: 'revenue',    field: 'amount', aggregate: 'sum', certified: true },
    { name: 'deal_count', aggregate: 'count' },
  ],
})

Aggregation lives on the dataset's measures (aggregate), not on the widget: count, sum, avg, min, max, count_distinct, array_agg, string_agg. A widget's dimensions and values must reference names declared on its bound dataset.

At runtime, dataset queries run through the analytics service and automatically apply the caller's row-level security scope to the base object and joined objects — a dashboard never shows a user records their permissions wouldn't.

The legacy inline-query shape (object + categoryField + valueField

  • aggregate directly on the widget) was removed. Define a dataset and bind widgets to it.

Widgets

PropertyTypeRequiredDescription
idstringyesUnique widget id (snake_case)
datasetstringyesDataset name to bind
valuesstring[]yesMeasure names (at least one)
dimensionsstring[]Dimension names — X / group / split
typeChartTypeVisualization type (default metric)
title / descriptionstringDisplay title and subtitle
filterFilterConditionPresentation-scope filter
layoutobjectGrid position (auto-flowed when omitted)
colorVariantenumKPI/card accent color
compareToenum | objectPeriod-over-period comparison window
filterBindingsobjectPer-widget global-filter mapping (below)

Chart types

TypeBest for
metric (default)Single-number KPI — revenue, count, percentage
bar / horizontal-bar / columnCategory comparison
lineTrends over time
pie / donutDistribution
areaVolume over time
scatterCorrelation
radarMulti-dimensional comparison
funnelConversion stages
gauge / solid-gauge / bullet / kpiProgress toward a goal
treemap / sankeyHierarchical proportions / flows
tableDetailed records — drill-through
pivotCross-tab summaries — drill-through

Layout

Widgets sit on a 12-column grid:

layout: {
  x: 0,    // column position (0-11)
  y: 0,    // row position
  w: 6,    // width in columns (1-12)
  h: 4,    // height in rows
}

Drill-down

table and pivot widgets are drill-through: clicking an aggregated row or cell opens a side drawer listing the underlying records behind that group. The dataset preserves raw group keys, so the drawer's filter matches the exact records — no label-to-id guessing. Click any row in the drawer to open that record's detail: the full group → records → record chain.

The drawer also offers an "Open in list →" escape hatch that escalates the peek to the object's full list page (sort, bulk-select, export, shareable URL), scoped by the same drill filter.

Drill-through is automatic — no per-widget configuration — whenever the dataset exposes the base object and the widget groups by at least one dimension. metric and chart widgets render the aggregate only; expose detail through a table or pivot widget instead.

Global date range and filters

A global time filter applies to all widgets:

dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true }

Preset ranges: today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days, custom.

Interactive global filters work the same way:

globalFilters: [
  { name: 'region', field: 'region', label: 'Region',    type: 'select' },
  { field: 'owner',                  label: 'Sales Rep', type: 'lookup' },
]

Each filter's name (defaults to field) is its stable identity — the key widgets reference in filterBindings and the dashboard-level variable readable in widget expressions as page.<name>. The name dateRange is reserved for the built-in date range.

Per-widget filter bindings

By default a filter applies to its own field on every widget. When a widget stores the concept under a different field — or should ignore a filter — declare filterBindings:

widgets: [
  // Default binding: dateRange → created_at, region → region.
  { id: 'invoices_by_status', /* … */ },
  // This widget's fields differ — map each filter explicitly.
  { id: 'accounts_signed',
    filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, /* … */ },
  // Opt out of a filter with `false`.
  { id: 'total_invoices', filterBindings: { region: false }, /* … */ },
]

Precedence: an explicit filterBindings entry (string override or false opt-out) → the filter's legacy targetWidgets allow-list → the filter's own field.

Surfacing a dashboard

Add a dashboard navigation entry to an app:

{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics',
  dashboardName: 'sales_overview', icon: 'bar-chart' }

Or describe it to the AI Builder"a sales overview dashboard with revenue by region and a monthly deal trend" — and review the generated dataset + dashboard metadata before approving.

Where to go next

PageWhy
AppsPut dashboards in navigation
ViewsInline chart list views over the same datasets
PagesFree-form layouts when a widget grid isn't enough
Data modelThe objects your datasets aggregate
PermissionsThe row-level security dashboards inherit

On this page