@object-ui/plugin-report
v17.6.0
Published
Report plugin for Object UI — schema-driven pivot tables, grouped aggregations and printable report layouts
Maintainers
Readme
@object-ui/plugin-report
Report components for Object UI — render, view and export reports, with scheduling support. Report authoring is not part of this package.
Features
- 🧩 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": "spec-report", "report": {...} }
Installation
npm install @object-ui/plugin-reportPeer Dependencies:
react^18.0.0 || ^19.0.0react-dom^18.0.0 || ^19.0.0
Quick Start
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';
// `dataSource` and `router` are host-provided.
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';
if (isDatasetReport(stored)) {
return <DatasetReportRenderer report={stored} dataSource={dataSource} />;
}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';
function LegacyReportPage() {
return (
<ReportViewer
schema={{ type: 'report-viewer', report: reportDefinition, data: rows, showToolbar: true }}
onRefresh={refetch}
/>
);
}Spec Reports — the four variants
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';
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.
Matrix (row × column pivot)
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.
Joined
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).
Ordering
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.
Embedded chart
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.
Server-side aggregation + drill-down
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';
<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.
Filter-time date helpers — current limitation
The server does not currently evaluate cel`...` expressions
embedded in filter values. Use module-load ISO strings instead:
const daysAgo = (n: number): string => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
};
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.
Stored pre-9.0 documents — migration only
⚠️ 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.
Legacy presentation layer
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.
Export
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.
Live Export
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';
// `myAdapter` is a host-provided DataSource (see @object-ui/data-*).
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';
exportExcelWithFormulas(report, rows, {
columns: [
{ name: 'amount', header: 'Amount', numberFormat: '#,##0.00' },
{ name: 'total', header: 'Total', formula: '=B{ROW}*C{ROW}' },
],
includeAggregationRow: true,
});Omitting columns derives them from report.fields. includeAggregationRow
appends a totals row built from each field's aggregation.
Scheduled export
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 { ReportComponentSchema, ReportScheduleConfig } from '@object-ui/types';
// `dataSource` and `notify` are host-provided.
const report: ReportComponentSchema = {
type: 'report',
title: 'Monthly Sales',
schedule: {
enabled: true,
frequency: 'monthly',
dayOfMonth: 1,
time: '07:00',
formats: ['pdf', 'excel'],
recipients: ['[email protected]'],
},
};
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.
Schema-Driven Usage
Importing the package registers three component types with ComponentRegistry:
| type | Component | Notes |
| --------------- | ---------------- | -------------------------------------------------- |
| report | ReportRenderer | The dispatcher. |
| spec-report | ReportRenderer | Spec-native alias; the report goes under report. |
| report-viewer | ReportViewer | Presentation-layer viewer. |
{
"type": "spec-report",
"report": {
"name": "opp_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.
Type-aware cell rendering
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.
Links
License
MIT — see LICENSE.
