npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@object-ui/plugin-report

v17.6.0

Published

Report plugin for Object UI — schema-driven pivot tables, grouped aggregations and printable report layouts

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 variantstabular / 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 for summary, all server-computed
  • 🎯 Cell drill-down — aggregated rows and matrix cells emit DatasetDrillArgs to the host's onDrill callback; 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 ReportViewer renders select → 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 ComponentRegistry on import; embed via { "type": "spec-report", "report": {...} }

Installation

npm install @object-ui/plugin-report

Peer Dependencies:

  • react ^18.0.0 || ^19.0.0
  • react-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 schemareport / 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 schemaobjectName 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 format

trigger() 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.