ObjectUIObjectUI

Plugin Report

Dataset-bound reports for Object UI — tabular, summary, matrix and joined views over a semantic-layer dataset, with server-computed totals, declared ordering, drill-down and printable export

@object-ui/plugin-report

Report engine for Object UI. It renders the four report variants defined by @objectstack/spec (tabular / summary / matrix / joined).

Since the ADR-0021 single-form cutover a report is dataset-bound: it names a semantic-layer dataset and selects that dataset's measures (values) grouped by that dataset's dimensions (rows, and for a matrix also columns). Every name in a report is a name defined in the dataset — the report never declares an object, a field, an aggregate or a date bucket of its own, and the aggregation runs in the semantic layer rather than in the browser.

Installation

pnpm add @object-ui/plugin-report

At a glance

VariantWhat it shows
tabularThe selection as a flat list — rows + values, no totals
summaryThe same table plus a server-computed grand-total footer
matrixA cross-tab: rows down × columns across, measures in the cells, plus subtotals
joinedA vertical stack of blocks, each its own dataset-bound table

All four are dispatched by a single <ReportRenderer schema={...} />. The declared type picks the presentation — never the shape of the data that comes back. The same report can be embedded in any JSON schema tree as { "type": "spec-report", "report": { ... } }; the dispatcher unwraps either shape.

The authoring shape

The authoring shape is Report, declared by @objectstack/spec/ui (ReportSchema) and shipped as json-schema/ui/Report.json — the same declaration defineReport validates against.

KeyTypeMeaning
namestring (required)Identifier, at least 2 characters.
labelstring | Record<string,string> (required)Display title; the record form is the i18n shape.
typetabular | summary | matrix | joinedDefaults to tabular.
datasetstringThe semantic-layer dataset this report reads.
rowsstring[]Dimension names to group down.
columnsstring[]Dimension names across — the matrix pivot axis.
valuesstring[]Measure names to display.
runtimeFilterfilter conditionScope filter, merged with any filter the host passes at render time.
order{ by, direction }[]Result ordering, most significant key first.
drilldownbooleanDefaults to true; set false to make rows and cells unclickable.
chartobjectEmbedded visualization over the same selection (see below).
blocksreport[]joined only — the stacked sub-reports.

The schema is strict: a key it does not declare is rejected, not ignored. columns in particular is a list of dimension names — passing column definition objects fails validation.

Quick start

defineReport validates the definition at authoring time, so a typo is a build error rather than an empty grid:

import { defineReport } from '@objectstack/spec/ui';

export const OpportunitiesByStage = defineReport({
  name: 'opp_by_stage',
  label: 'Opportunities by Stage',
  type: 'summary',
  dataset: 'opportunity_pipeline',
  rows: ['stage'],
  values: ['amount_sum', 'deal_count'],
});

amount_sum and deal_count are measures the opportunity_pipeline dataset defines; stage is one of its dimensions. The report picks from that vocabulary and adds nothing to it.

import { ReportRenderer } from '@object-ui/plugin-report';

<ReportRenderer schema={OpportunitiesByStage} dataSource={dataSource} />

The dataSource must implement queryDataset(dataset, selection). A data source without it renders an explicit error — "This data source does not support dataset queries" — rather than an empty table.

Matrix reports (row × column pivot)

A matrix adds columns: a second list of dimension names, used as the across axis.

import { defineReport } from '@objectstack/spec/ui';

export const PipelineByQuarter = defineReport({
  name: 'pipeline_by_quarter',
  label: 'Pipeline Coverage by Quarter',
  type: 'matrix',
  dataset: 'opportunity_pipeline',
  rows: ['forecast_category'],
  columns: ['close_quarter'],
  values: ['amount_sum'],
});

Period pivots come from the dataset: close_quarter is a time dimension the dataset declares at that grain. A report cannot bucket a date itself — there is no per-grouping granularity key in the report shape, because the grain a measure is valid at is a property of the semantic layer, not of one report.

Row subtotals, column subtotals and the grand total are server-computed: the selection asks for them and the renderer only places the pre-aggregated rows it is handed. It never re-combines bucketed values client-side, since measures like an average cannot be recombined without drifting from the semantic layer. An older server that returns no totals renders the plain cross-tab without the totals row and column, and a matrix that declares no columns degrades to the flat grouped table.

Joined reports

A joined report stacks independent blocks vertically — each block is its own dataset-bound report, so different panels may read different datasets.

import { defineReport } from '@objectstack/spec/ui';

export const CustomerChurnSignals = defineReport({
  name: 'customer_churn_signals',
  label: 'Customer Churn Signals',
  type: 'joined',
  blocks: [
    {
      name: 'at_risk_accounts',
      label: 'At-Risk Accounts',
      type: 'summary',
      dataset: 'account_health',
      rows: ['industry'],
      values: ['account_count'],
      runtimeFilter: { is_active: true },
    },
    {
      name: 'recently_lost',
      label: 'Recently Lost Opportunities',
      type: 'summary',
      dataset: 'opportunity_pipeline',
      rows: ['owner'],
      values: ['amount_sum', 'deal_count'],
      runtimeFilter: { stage: 'closed_lost' },
    },
  ],
});

Block rules, as the renderer applies them:

  • Each block names its own dataset — there is no container-level dataset to inherit.
  • The container's runtimeFilter is merged into every block; the block's own keys win on collision.
  • Each block orders itself through its own order; a joined container has no report-level ordering to hand down.
  • A block's declared type picks its presentation, exactly as at top level, and a block must not itself be joined (no recursion).

Ordering

order is a list, most significant key first, and it is lowered onto the dataset selection — the server orders the query:

order: [
  { by: 'stage', direction: 'asc' },
  { by: 'amount_sum', direction: 'desc' },
]

by names a dimension the report groups by or a measure it displays. Because the server sorts, ordering by a derived measure works and the sort applies to the whole result rather than to one fetched page. For a matrix the across axis reads left-to-right in row-arrival order, so ordering by the across dimension is what makes the columns come out in that order; declaring nothing still reads correctly, because a selected time dimension defaults to ascending.

Embedded chart

A report may carry a chart object rendered over the same selection. Its type is one of the spec's chart names, and xAxis / yAxis are bare dimension and measure names taken from the report's own selection — not field paths and not expressions. title, subtitle, series, colors, height, showLegend, showDataLabels, annotations and interaction are passed through as authored. See the package README for a complete chart block.

Drill-down

Drill-down is a host callback, not a registered handler. Pass onDrill and every aggregated row and matrix cell becomes clickable; supply nothing and nothing is clickable. The report emits what was clicked and the host decides where that goes, because the renderer only knows dimension names:

import { ReportRenderer, type DatasetDrillArgs } from '@object-ui/plugin-report';

<ReportRenderer
  schema={OpportunitiesByStage}
  dataSource={dataSource}
  onDrill={(args: DatasetDrillArgs) => {
    const filter = { ...args.objectFilter, ...args.runtimeFilter };
    router.push(`/records/${args.object}?filter=${encodeURIComponent(JSON.stringify(filter))}`);
  }}
/>
DatasetDrillArgsMeaning
datasetDataset the clicked aggregate was computed over.
groupKeyDimension name → clicked bucket value (row dimensions, plus across dimensions for a matrix cell).
runtimeFilterThe effective render-time scope filter, if any.
objectThe dataset's base object, when the server supplied it.
objectFilterExact record-list filter (object field name → raw stored value) for the clicked bucket, present only when the server returned the dimension→field mapping and the raw grouped values. It is what lets select and lookup dimensions filter precisely instead of by display label.

A report can opt out with drilldown: false even when the host passes onDrill.

Where the numbers come from

The renderer posts one dataset selection — dimensions, measures, and optionally runtimeFilter, totals and order — through dataSource.queryDataset. That is the same governed path dataset-bound dashboard widgets and the dataset preview use, which is why a number shown in a report matches the same number shown on a dashboard.

Presentation follows from what the server returns: column headers use the dataset's server-supplied display label, and measure cells are formatted with the field's declared currency and numeral format rather than the raw measure name.

Stored pre-9.0 documents — migration only

Not an authoring option

This section is about documents that already exist in storage. The shape it describes is not available for authoring. Everything above is how a report is written; nothing below is.

Before the ADR-0021 cutover a report carried its own query: an objectName, columns as column definition objects with aggregate, and groupingsDown / groupingsAcross with sortOrder and dateGranularity. That form is rejected by the current schemaobjectName and groupingsDown come back as unrecognized keys, and object-shaped columns fails as the wrong type.

Stored documents in that shape still render, through a deliberately lossy bridge: the dispatcher converts them to a presentation-layer report and hands them to the viewer. The conversion reads the current keys, so a pre-9.0 document arrives with none of them — what it produces is the report's title and an empty column set. The document renders; its columns, groupings, aggregates and sort do not. There is no diagnostic, which is exactly why the bridge is a waiting room and not a supported way to write a report.

Migrating one is a re-expression against the dataset:

Pre-9.0 keyWhere it goes now
objectNameGone from the report — the dataset owns the object.
columns: [{ field, aggregate }]values: string[]; the aggregate becomes a measure in the dataset.
columns: [{ field }] (non-aggregated)rows: string[], as a dimension.
groupingsDown: [{ field }]rows: string[].
groupingsAcross: [{ field }]columns: string[].
sortOrder on a groupingorder: [{ by, direction }] on the report.
dateGranularity on a groupingA time dimension declared at that grain in the dataset.
filterruntimeFilter.

The work that has no mechanical equivalent is the dataset itself: measures and time dimensions the old report declared inline have to exist in the semantic layer before the migrated report can name them.

Filter-time date helpers — current limitation

The server does not currently evaluate cel`...` expressions embedded inside filter values; the unevaluated template object reaches the driver. Compute concrete ISO date strings instead and treat the report as a snapshot:

// Module-load helper. Re-evaluates each time the app boots.
const daysAgo = (n: number): string => {
  const d = new Date();
  d.setUTCDate(d.getUTCDate() - n);
  return d.toISOString().slice(0, 10); // 'YYYY-MM-DD'
};

runtimeFilter: { last_activity_date: { $lt: daysAgo(60) } }

For a sliding window, recompute on each render or key a useMemo on a one-hour bucket. Native filter-time CEL evaluation is tracked for a future major version.

Schema-driven embedding

Importing the package registers three component types with ComponentRegistry: report and spec-report (both the dispatcher) and report-viewer (the presentation-layer viewer). To embed a report in any JSON schema tree:

{
  "type": "spec-report",
  "report": {
    "name": "opp_by_stage",
    "label": "Opportunities by Stage",
    "type": "summary",
    "dataset": "opportunity_pipeline",
    "rows": ["stage"],
    "values": ["amount_sum"]
  }
}

There is no report-builder component type: report authoring lives in the console designer, not in this package.

Further reading

On this page