Report components for Object UI — render, view and export reports, with scheduling support. Report authoring is not part of this package.
- 🧩 Four spec report variants —
tabular/summary/matrix/joined, dispatched by a single<ReportRenderer schema={...}> - 🧮 Server-side aggregation — a dataset-bound report selects measures by name through
dataSource.queryDataset; totals come back pre-aggregated (ADR-0021) - 📅 Period pivots — the across axis is a dimension the dataset declares at the grain it needs; a report buckets nothing itself
- 🪜 Multi-level grouping + totals — row subtotals, column subtotals and grand total for
matrix, a grand-total footer forsummary, all server-computed - 🎯 Cell drill-down — aggregated rows and matrix cells emit
DatasetDrillArgsto the host'sonDrillcallback; the host owns navigation (ADR-0021 D2) - 🧱 Joined reports — vertically stacked sub-reports; each block names its own
dataset, filter and ordering - 🎨 Type-aware cells — the presentation-layer
ReportViewerrendersselect→ Badge,lookup→ link,boolean→ ✓/✗,email/url/phone→ links,image→ thumbnail - 🖨️ Multi-format export — CSV, JSON, HTML, PDF, Excel; live-data and Excel-formula variants
- 📦 Auto-registered — components register with
ComponentRegistryon import; embed via{ "type": "report", "report": {...} }
npm install @object-ui/plugin-reportPeer Dependencies:
react^18.0.0 || ^19.0.0react-dom^18.0.0 || ^19.0.0
ReportRenderer is the one entry point — a dispatcher that routes any report
shape to the right renderer. It takes the report as schema; there is no
report-authoring component in this package (see
Legacy presentation layer).
import { ReportRenderer } from '@object-ui/plugin-report';
import type { DataSource } from '@object-ui/types';
// `dataSource` and `router` are host-provided.
declare const dataSource: DataSource;
declare const router: { push: (href: string) => void };
function ReportPage() {
return (
<ReportRenderer
schema={{
name: 'opp_by_stage',
type: 'summary',
dataset: 'opportunity_pipeline',
rows: ['stage'],
values: ['amount_sum', 'deal_count'],
}}
dataSource={dataSource}
onDrill={({ object }) => router.push(`/records/${object}`)}
/>
);
}The dataset-bound renderer can also be addressed directly when the host has
already decided the shape — it takes the report as report, not schema:
import { DatasetReportRenderer, isDatasetReport } from '@object-ui/plugin-report';
import type { DataSource } from '@object-ui/types';
// `stored` is whatever the host loaded; `dataSource` is host-provided.
declare const stored: unknown;
declare const dataSource: DataSource;
function StoredReportPage() {
if (isDatasetReport(stored)) {
return <DatasetReportRenderer report={stored} dataSource={dataSource} />;
}
return null;
}ReportViewer renders a presentation-layer report, and takes a whole
ReportViewerSchema as schema — report / showToolbar are keys inside
that schema, not props of the component:
import { ReportViewer } from '@object-ui/plugin-report';
import type { ReportComponentSchema } from '@object-ui/types';
// All three are host-provided.
declare const reportDefinition: ReportComponentSchema;
declare const rows: Array<Record<string, unknown>>;
declare const refetch: () => void;
function LegacyReportPage() {
return (
<ReportViewer
schema={{ type: 'report-viewer', report: reportDefinition, data: rows, showToolbar: true }}
onRefresh={refetch}
/>
);
}A report is dataset-bound: it names a semantic-layer dataset and selects
that dataset's measures (values) grouped by its dimensions (rows, and for a
matrix also columns). Every name in a report is a name the dataset defines —
the report declares no object, no field, no aggregate and no date bucket of its
own. defineReport validates at authoring time and returns the parsed shape
ReportRenderer takes as schema:
import { defineReport } from '@objectstack/spec/ui';
import { ReportRenderer } from '@object-ui/plugin-report';
import type { DataSource } from '@object-ui/types';
// `ds` is host-provided.
declare const ds: DataSource;
const report = defineReport({
name: 'opp_by_stage',
label: 'Opportunities by Stage',
type: 'summary',
dataset: 'opportunity_pipeline',
rows: ['stage'],
values: ['amount_sum', 'deal_count'],
order: [{ by: 'amount_sum', direction: 'desc' }],
});
<ReportRenderer schema={report} dataSource={ds} />| Key | Type | Meaning |
|---|---|---|
name |
string (required) |
Identifier, at least 2 characters. |
label |
string | Record< string, string > (required) |
Display title; the record form is the i18n shape. |
type |
tabular | summary | matrix | joined |
Defaults to tabular. |
dataset |
string |
The dataset this report reads. |
rows |
string[] |
Dimension names to group down. |
columns |
string[] |
Dimension names across — the matrix pivot axis. |
values |
string[] |
Measure names to display. |
runtimeFilter |
filter condition | Scope filter, merged with the filter the host passes at render. |
order |
{ by, direction }[] |
Result ordering, most significant key first. |
drilldown |
boolean |
Defaults to true. |
chart |
object | Embedded visualization over the same selection. |
blocks |
report[] | joined only — the stacked sub-reports. |
The schema is strict: a key it does not declare is rejected, not ignored.
type |
Description |
|---|---|
tabular |
The selection as a flat list — rows + values, no totals |
summary |
The same table plus a server-computed grand-total footer |
matrix |
Cross-tab: rows down × columns across, measures in the cells, plus subtotals |
joined |
A vertical stack of blocks, each its own dataset-bound table |
The declared type picks the presentation — never the shape of the data that comes back.
columns is a second list of dimension names, used as the across axis:
import { defineReport } from '@objectstack/spec/ui';
defineReport({
name: 'pipeline_by_quarter',
label: 'Pipeline Coverage by Quarter',
type: 'matrix',
dataset: 'opportunity_pipeline',
rows: ['forecast_category'],
columns: ['close_quarter'],
values: ['amount_sum'],
});close_quarter is a time dimension the dataset declares at that grain. A
report cannot bucket a date itself: the grain a measure is valid at belongs to
the semantic layer, not to one report. A matrix with no columns degrades to
the flat grouped table.
import { defineReport } from '@objectstack/spec/ui';
defineReport({
name: 'churn_signals',
label: 'Customer Churn Signals',
type: 'joined',
blocks: [
{
name: 'at_risk',
label: 'At-Risk Accounts',
type: 'summary',
dataset: 'account_health',
rows: ['industry'],
values: ['account_count'],
runtimeFilter: { is_active: true },
},
{
name: 'lost',
label: 'Recently Lost',
type: 'summary',
dataset: 'opportunity_pipeline',
rows: ['owner'],
values: ['amount_sum'],
runtimeFilter: { stage: 'closed_lost' },
},
],
});Block rules: every block names its own dataset (there is no container
dataset to inherit); the container's runtimeFilter is merged into each block
and the block's own keys win; each block orders itself through its own order;
a block's declared type picks its presentation; block.type must not be
joined (no recursion).
order is a list, most significant key first, lowered onto the dataset
selection so the server orders the query — which is why ordering by a
derived measure works and the sort applies to the whole result rather than to
one fetched page. by names a dimension the report groups by or a measure it
displays. For a matrix the across axis reads left-to-right in row-arrival
order, so ordering by the across dimension is what fixes the column order;
declaring nothing still reads correctly, because a selected time dimension
defaults to ascending.
chart.type is one of the spec's chart names; xAxis / yAxis are bare
dimension and measure names from the report's own selection, not field paths
and not expressions:
import { defineReport } from '@objectstack/spec/ui';
defineReport({
name: 'pipeline_by_stage',
label: 'Pipeline by Stage',
type: 'summary',
dataset: 'opportunity_pipeline',
rows: ['stage'],
values: ['amount_sum'],
chart: {
type: 'bar',
title: 'Pipeline by Stage',
xAxis: 'stage',
yAxis: 'amount_sum',
showLegend: false,
},
});series, colors, height, showDataLabels, annotations and interaction
are passed through to the chart as authored.
The chart runs its own xAxis × yAxis dataset query, so it binds the same way
on every non-joined type. It draws above a tabular or summary table, and
below the cross-tab of a matrix that declares columns; there xAxis may name
a rows or a columns dimension.
A dataset-bound report selects its dimensions (rows, and for matrix also
columns) and measures (values) by name and runs them through
dataSource.queryDataset — the same governed semantic-layer path that
dataset-bound dashboard widgets and the dataset preview use, so the numbers and
the server-resolved dimension labels match everywhere. Totals (row subtotals,
column subtotals, grand total) are server-computed: the renderer places the
pre-aggregated rows it is handed and never re-aggregates bucketed values
client-side, since measures like avg cannot be recombined without drifting
from the semantic layer.
Drill-down is a host callback, not a registered handler: pass onDrill and
every aggregated row / matrix cell becomes clickable. The report emits what was
clicked; the host decides where that goes, because the renderer only knows
dimension names (ADR-0021 D2).
import { ReportRenderer, type DatasetDrillArgs } from '@object-ui/plugin-report';
import type { DataSource, SpecReport } from '@object-ui/types';
// `report`, `dataSource` and `router` are host-provided.
declare const report: SpecReport;
declare const dataSource: DataSource;
declare const router: { push: (href: string) => void };
<ReportRenderer
schema={report}
dataSource={dataSource}
onDrill={(args: DatasetDrillArgs) => {
// The host resolves dataset → object and dimension → field, then navigates.
const filter = { ...args.objectFilter, ...args.runtimeFilter };
router.push(`/records/${args.object}?filter=${encodeURIComponent(JSON.stringify(filter))}`);
}}
/>What a drill click carries:
DatasetDrillArgs |
Meaning |
|---|---|
dataset |
Dataset the clicked aggregate was computed over. |
groupKey |
Dimension name → clicked bucket value (row dims, plus across dims for a matrix cell). |
runtimeFilter |
The effective render-time scope filter, if any. |
object |
The dataset's base object, when the server supplied it. |
objectFilter |
Exact record-list filter (object field name → raw stored value) for the clicked bucket. Present only when the server returned the dimension→field mapping plus raw grouped values — that is what lets select/lookup dimensions filter precisely instead of by display label. |
Supply no onDrill and nothing is clickable; a report can also opt out with
drilldown: false.
The server does not currently evaluate cel`...` expressions
embedded in filter values. Use module-load ISO strings instead:
import { defineReport } from '@objectstack/spec/ui';
const daysAgo = (n: number): string => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
};
defineReport({
name: 'recent_pipeline',
label: 'Recent Pipeline',
type: 'summary',
dataset: 'opportunity_pipeline',
rows: ['stage'],
values: ['amount_sum'],
runtimeFilter: { close_date: { $gte: daysAgo(30) } },
});See the bundled CRM customer_churn_signals demo for the full pattern.
Native filter-time CEL evaluation is tracked for a future major version.
⚠️ 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 here 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 schema — objectName and
groupingsDown come back as unrecognized keys, and object-shaped columns
fails as the wrong type (columns is now a list of dimension names).
Stored documents in that shape still render, through a deliberately lossy
bridge: ReportRenderer converts them to a presentation report and hands that
to ReportViewer. The converter reads the current keys (values, rows), so
a pre-9.0 document arrives with none of them and what comes out is the report's
title over an empty column set. The document renders; its columns,
groupings, aggregates and sort do not, and nothing reports an error. That is why
the bridge is a waiting room rather than a supported way to write a report.
Migrating one is a re-expression against the dataset:
| Pre-9.0 key | Where it goes now |
|---|---|
objectName |
Gone from the report — the dataset owns the object. |
columns: [{ field, aggregate }] |
values: string[]; the aggregate becomes a measure in the dataset. |
columns: [{ field }] |
rows: string[], as a dimension. |
groupingsDown: [{ field }] |
rows: string[]. |
groupingsAcross: [{ field }] |
columns: string[]. |
sortOrder on a grouping |
order: [{ by, direction }] on the report. |
dateGranularity on a grouping |
A time dimension declared at that grain in the dataset. |
filter |
runtimeFilter. |
The part with no mechanical equivalent is the dataset itself: measures and time dimensions the old report declared inline must exist in the semantic layer before the migrated report can name them.
The query-form renderers that used to draw these documents inline
(SpecReportGrid, MatrixRenderer, JoinedReportRenderer) were removed at the
cutover and have no replacement export — the bridge above is what renders a
stored old-shape document now.
ReportViewer, LegacyReportRenderer and the export functions below remain
available for pre-spec presentation reports ({ report, data, columns, chart })
and are not affected by the dataset-bound pipeline above.
The authoring components that used to sit alongside them — ReportBuilder,
ScheduleConfig, ChartConfig, the columns/groupings editors — and the
registerDrillHandler-style drill helpers were removed in the 9.0 cutover
(see CHANGELOG.md). They have no replacement export in this package: report
authoring lives in the console designer, and drill-down is the onDrill
callback above.
Every format exporter takes the report schema first and the rows second —
(report: ReportComponentSchema, data: any[], config?: ReportExportConfig). The
schema is where the exporter reads the columns (report.fields) and the download
name (config.filename, else report.title) from; there is no filename
parameter. All five return void synchronously — they trigger a browser
download — so there is nothing to await:
import {
exportReport,
exportAsCSV,
exportAsJSON,
exportAsHTML,
exportAsPDF,
exportAsExcel,
} from '@object-ui/plugin-report';
import type { ReportComponentSchema } from '@object-ui/types';
const report: ReportComponentSchema = {
type: 'report',
title: 'Sales Report',
fields: [
{ name: 'account', label: 'Account' },
{ name: 'amount', label: 'Amount', type: 'currency', aggregation: 'sum' },
],
};
const rows = [{ account: 'Acme', amount: 12000 }];
exportAsCSV(report, rows); // downloads "Sales Report.csv"
exportAsJSON(report, rows); // "Sales Report.json"
exportAsHTML(report, rows); // "Sales Report.html"
exportAsPDF(report, rows); // opens a print window; falls back to .html if blocked
exportAsExcel(report, rows, { format: 'excel', filename: 'sales-2026-q1.tsv' });
// `exportReport` is the router — the format comes FIRST, then the same triple.
exportReport('csv', report, rows);exportAsExcel writes a BOM-prefixed TSV that Excel opens, so its default
name is report.title + .tsv, not .xlsx. config.includeHeaders: false drops the
header row; orientation / pageSize apply to the HTML and PDF paths only.
exportWithLiveData fetches the rows itself, so dataSource and resource are
required in LiveExportOptions — an options object carrying only format
does not compile. It is the one async export function, resolving to a
LiveExportResult:
import { exportWithLiveData } from '@object-ui/plugin-report';
import type { DataSource, ReportComponentSchema } from '@object-ui/types';
// `myAdapter` is a host-provided DataSource (see @object-ui/data-*).
declare const myAdapter: DataSource;
declare const report: ReportComponentSchema;
const result = await exportWithLiveData(report, {
dataSource: myAdapter,
resource: 'orders',
format: 'pdf',
});
// -> { success: true, recordCount: 42, format: 'pdf' }The format falls back to report.defaultExportFormat, then 'pdf'. Failures are
returned, not thrown: { success: false, recordCount: 0, format, error }.
exportExcelWithFormulas is a separate, synchronous three-parameter function —
(report, data, options) — with the column list inside the third argument. A
column is { name, header, width?, numberFormat?, formula? }: name is the key
read off each row (there is no field key), header is required, and a formula
is a template in which the {ROW} placeholder is replaced by that row's
spreadsheet line number:
import { exportExcelWithFormulas } from '@object-ui/plugin-report';
import type { ReportComponentSchema } from '@object-ui/types';
declare const report: ReportComponentSchema;
declare const rows: Array<Record<string, unknown>>;
exportExcelWithFormulas(report, rows, {
columns: [
{ name: 'amount', header: 'Amount', numberFormat: '#,##0.00' },
{ name: 'total', header: 'Total', formula: '=B{ROW}*C{ROW}' },
],
includeAggregationRow: true,
locale: 'de-DE',
});Omitting columns derives them from report.fields. includeAggregationRow
appends a totals row built from each field's aggregation.
locale is the BCP-47 tag the numeric cells are formatted in — above, an
amount of 1234.5 writes 1.234,50 rather than 1,234.50. Pass the tag the
session renders in: useDisplayLocale() from @object-ui/i18n in React, or
whatever your non-React caller resolved. This function is not a component and a
display locale is a property of the session rather than of the authored report
schema, so its caller is the only place the tag can come from. Omitted, cells
format in 'en' — the display channel's own last resort, which is neither
'en-US' nor the machine's locale (objectui#10020).
A schedule is data on the report schema, not a component: it is
ReportComponentSchema.schedule, typed ReportScheduleConfig. Both types live
in @object-ui/types and are not re-exported here — importing them from this
package is a TS2305.
createScheduleTrigger turns that declared schedule into a callable a workflow
engine can invoke. It takes the report, the data source, the resource to query
and a completion callback:
import { createScheduleTrigger } from '@object-ui/plugin-report';
import type { DataSource, ReportComponentSchema, ReportScheduleConfig } from '@object-ui/types';
// `dataSource` and `notify` are host-provided.
declare const dataSource: DataSource;
declare const notify: (recipients: string[], subject: string | undefined) => void;
const report: ReportComponentSchema = {
type: 'report',
title: 'Monthly Sales',
schedule: {
enabled: true,
frequency: 'monthly',
dayOfMonth: 1,
time: '07:00',
formats: ['pdf', 'excel'],
recipients: ['sales@example.com'],
},
};
const trigger = createScheduleTrigger(
report,
dataSource,
'orders',
(exported: ReportComponentSchema, schedule: ReportScheduleConfig) => {
notify(schedule.recipients ?? [], schedule.subject ?? exported.title);
},
);
const results = await trigger(); // LiveExportResult[] — one entry per scheduled formattrigger() reads report.schedule itself: a schedule that is missing or
enabled: false exports nothing and resolves to []. Otherwise it exports once
per entry in schedule.formats (falling back to report.defaultExportFormat,
then 'pdf') and calls the completion callback with the report and the schedule
it ran.
Importing the package registers two component types with ComponentRegistry:
type |
Component | Notes |
|---|---|---|
report |
ReportRenderer |
The dispatcher. A spec report goes under its report key. |
report-viewer |
ReportViewer |
Presentation-layer viewer. |
To embed a report, wrap the @objectstack/spec report definition (ReportSchema)
in a report node's report member. When report is present, the node renders
that report and reads none of its own presentation keys.
{
"type": "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 — the authoring component that name
addressed was removed in the 9.0 cutover, so a node declaring it resolves to
nothing. spec-report is retired too (objectui#11440): it was an alias of report
carrying the same wrapper, and a node declaring it now resolves to nothing. Write
"type": "report" instead; the report member is unchanged.
ReportViewer delegates cell rendering to the shared getCellRenderer
registry from @object-ui/fields, so each column is rendered with the
component appropriate for its type — instead of String(value).
field.type |
Rendering |
|---|---|
text / string |
Plain text |
number / currency / percent |
Locale-formatted, optional currency/percent symbol |
boolean |
✓ / ✗ icons |
date / datetime / time |
Localised date/time |
select / multi_select / status |
Badge(s), label resolved from options, color from option.color or colorMap |
lookup / reference / master_detail |
Linked record name (id fallback), deep-link to /console/apps/<app>/<referenceTo>/record/<id> |
email |
mailto: link |
url |
External link (target="_blank") |
phone |
tel: link |
image |
Inline thumbnail |
file |
Filename + download link |
user / owner |
Avatar + name |
richtext / html / markdown |
Sanitised inline content |
json |
Collapsed code preview |
These are presentation-layer columns: each entry in report.fields carries its
own name, label and type, and that declared type is what selects the
renderer. This package resolves nothing from object metadata — there is no
objectName on a report to hydrate from, and a column with no type renders
as plain text.
Legacy renderAs: 'badge' + colorMap is still honoured for plain
string columns.
MIT — see LICENSE.