@object-ui/plugin-report 17.5.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # @object-ui/plugin-report
2
2
 
3
- Report components for Object UI — build, view, render, and export reports with scheduling support.
3
+ Report components for Object UI — render, view and export reports, with scheduling support. Report *authoring* is not part of this package.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - 🧩 **Four spec report variants** — `tabular` / `summary` / `matrix` / `joined`, dispatched by a single `<ReportRenderer schema={...}>`
8
- - 🧮 **Server-side aggregation** — `useReportData()` posts spec `QueryAST` to `POST /api/v1/data/:object/query`; transparent in-memory fallback
9
- - 📅 **Date bucketing** — `dateGranularity: day|week|month|quarter|year` on `groupingsAcross` / `groupingsDown`
10
- - 🪜 **Multi-level grouping + totals** — row totals, column totals, grand totals for matrix; tabular/summary delegate to `ObjectGrid`
11
- - 🎯 **Cell drill-down** — every aggregated cell dispatches a `drill` action via `ActionRunner`; targets List view or a nested Report (M3)
12
- - 🧱 **Joined reports** — vertically stacked sub-reports; each block owns its own `objectName`, filter and data fetch
13
- - 🎨 **Type-aware cells** — `select` → Badge, `lookup` → link, `boolean` → ✓/✗, `email`/`url`/`phone` → links, `image` → thumbnail (auto-hydrated from object metadata)
8
+ - 🧮 **Server-side aggregation** — a dataset-bound report selects measures by name through `dataSource.queryDataset`; totals come back pre-aggregated (ADR-0021)
9
+ - 📅 **Period pivots** — the across axis is a dimension the dataset declares at the grain it needs; a report buckets nothing itself
10
+ - 🪜 **Multi-level grouping + totals** — row subtotals, column subtotals and grand total for `matrix`, a grand-total footer for `summary`, all server-computed
11
+ - 🎯 **Cell drill-down** — aggregated rows and matrix cells emit `DatasetDrillArgs` to the host's `onDrill` callback; the host owns navigation (ADR-0021 D2)
12
+ - 🧱 **Joined reports** — vertically stacked sub-reports; each block names its own `dataset`, filter and ordering
13
+ - 🎨 **Type-aware cells** — the presentation-layer `ReportViewer` renders `select` → Badge, `lookup` → link, `boolean` → ✓/✗, `email`/`url`/`phone` → links, `image` → thumbnail
14
14
  - 🖨️ **Multi-format export** — CSV, JSON, HTML, PDF, Excel; live-data and Excel-formula variants
15
15
  - 📦 **Auto-registered** — components register with `ComponentRegistry` on import; embed via `{ "type": "spec-report", "report": {...} }`
16
16
 
@@ -26,23 +26,55 @@ npm install @object-ui/plugin-report
26
26
 
27
27
  ## Quick Start
28
28
 
29
+ `ReportRenderer` is the one entry point — a dispatcher that routes any report
30
+ shape to the right renderer. It takes the report as `schema`; there is no
31
+ report-authoring component in this package (see
32
+ [Legacy presentation layer](#legacy-presentation-layer)).
33
+
29
34
  ```tsx
30
- import { ReportBuilder, ReportViewer, ReportRenderer } from '@object-ui/plugin-report';
35
+ import { ReportRenderer } from '@object-ui/plugin-report';
31
36
 
32
- function ReportEditorPage() {
33
- return <ReportBuilder report={initialReport} />;
37
+ // `dataSource` and `router` are host-provided.
38
+ function ReportPage() {
39
+ return (
40
+ <ReportRenderer
41
+ schema={{
42
+ name: 'opp_by_stage',
43
+ type: 'summary',
44
+ dataset: 'opportunity_pipeline',
45
+ rows: ['stage'],
46
+ values: ['amount_sum', 'deal_count'],
47
+ }}
48
+ dataSource={dataSource}
49
+ onDrill={({ object }) => router.push(`/records/${object}`)}
50
+ />
51
+ );
34
52
  }
53
+ ```
54
+
55
+ The dataset-bound renderer can also be addressed directly when the host has
56
+ already decided the shape — it takes the report as `report`, not `schema`:
57
+
58
+ ```tsx
59
+ import { DatasetReportRenderer, isDatasetReport } from '@object-ui/plugin-report';
35
60
 
36
- function ReportViewPage() {
37
- return <ReportViewer report={reportDefinition} showToolbar />;
61
+ if (isDatasetReport(stored)) {
62
+ return <DatasetReportRenderer report={stored} dataSource={dataSource} />;
38
63
  }
64
+ ```
65
+
66
+ `ReportViewer` renders a presentation-layer report, and takes a whole
67
+ `ReportViewerSchema` as `schema` — `report` / `showToolbar` are keys *inside*
68
+ that schema, not props of the component:
39
69
 
40
- function EmbeddedReport() {
70
+ ```tsx
71
+ import { ReportViewer } from '@object-ui/plugin-report';
72
+
73
+ function LegacyReportPage() {
41
74
  return (
42
- <ReportRenderer
43
- title="Monthly Sales"
44
- description="Sales performance overview"
45
- chart={chartConfig}
75
+ <ReportViewer
76
+ schema={{ type: 'report-viewer', report: reportDefinition, data: rows, showToolbar: true }}
77
+ onRefresh={refetch}
46
78
  />
47
79
  );
48
80
  }
@@ -50,87 +82,201 @@ function EmbeddedReport() {
50
82
 
51
83
  ## Spec Reports — the four variants
52
84
 
53
- The plugin renders any `Report` defined by `@objectstack/spec`:
85
+ A report is **dataset-bound**: it names a semantic-layer `dataset` and selects
86
+ that dataset's measures (`values`) grouped by its dimensions (`rows`, and for a
87
+ matrix also `columns`). Every name in a report is a name the dataset defines —
88
+ the report declares no object, no field, no aggregate and no date bucket of its
89
+ own. `defineReport` validates at authoring time and returns the parsed shape
90
+ `ReportRenderer` takes as `schema`:
54
91
 
55
92
  ```ts
56
- import type { ReportInput } from '@objectstack/spec/ui';
93
+ import { defineReport } from '@objectstack/spec/ui';
57
94
  import { ReportRenderer } from '@object-ui/plugin-report';
58
95
 
59
- const report: ReportInput = {
96
+ const report = defineReport({
60
97
  name: 'opp_by_stage',
61
- objectName: 'opportunity',
98
+ label: 'Opportunities by Stage',
62
99
  type: 'summary',
63
- columns: [
64
- { field: 'stage' },
65
- { field: 'amount', aggregate: 'sum' },
66
- { field: 'id', label: 'Deals', aggregate: 'count' },
67
- ],
68
- groupingsDown: [{ field: 'stage', sortOrder: 'asc' }],
69
- };
100
+ dataset: 'opportunity_pipeline',
101
+ rows: ['stage'],
102
+ values: ['amount_sum', 'deal_count'],
103
+ order: [{ by: 'amount_sum', direction: 'desc' }],
104
+ });
70
105
 
71
106
  <ReportRenderer schema={report} dataSource={ds} />
72
107
  ```
73
108
 
74
- | `type` | Description |
75
- | --------- | ---------------------------------------------------- |
76
- | `tabular` | Flat record list |
77
- | `summary` | Single-axis grouped + aggregated |
78
- | `matrix` | Row × column pivot with cell aggregates and totals |
79
- | `joined` | Vertically stacked sub-reports, each with own data |
109
+ | Key | Type | Meaning |
110
+ | --------------- | ---------------------------------------------- | ---------------------------------------------------------------- |
111
+ | `name` | `string` (required) | Identifier, at least 2 characters. |
112
+ | `label` | `string` \| `Record< string, string >` (required) | Display title; the record form is the i18n shape. |
113
+ | `type` | `tabular` \| `summary` \| `matrix` \| `joined` | Defaults to `tabular`. |
114
+ | `dataset` | `string` | The dataset this report reads. |
115
+ | `rows` | `string[]` | Dimension **names** to group down. |
116
+ | `columns` | `string[]` | Dimension **names** across — the matrix pivot axis. |
117
+ | `values` | `string[]` | Measure **names** to display. |
118
+ | `runtimeFilter` | filter condition | Scope filter, merged with the filter the host passes at render. |
119
+ | `order` | `{ by, direction }[]` | Result ordering, most significant key first. |
120
+ | `drilldown` | `boolean` | Defaults to `true`. |
121
+ | `chart` | object | Embedded visualization over the same selection. |
122
+ | `blocks` | report[] | `joined` only — the stacked sub-reports. |
123
+
124
+ The schema is **strict**: a key it does not declare is rejected, not ignored.
125
+
126
+ | `type` | Description |
127
+ | --------- | ------------------------------------------------------------------------------ |
128
+ | `tabular` | The selection as a flat list — `rows` + `values`, no totals |
129
+ | `summary` | The same table plus a server-computed grand-total footer |
130
+ | `matrix` | Cross-tab: `rows` down × `columns` across, measures in the cells, plus subtotals |
131
+ | `joined` | A vertical stack of blocks, each its own dataset-bound table |
132
+
133
+ The **declared type** picks the presentation — never the shape of the data that
134
+ comes back.
80
135
 
81
136
  ### Matrix (row × column pivot)
82
137
 
138
+ `columns` is a second list of dimension **names**, used as the across axis:
139
+
83
140
  ```ts
84
- {
141
+ import { defineReport } from '@objectstack/spec/ui';
142
+
143
+ defineReport({
85
144
  name: 'pipeline_by_quarter',
86
- objectName: 'opportunity',
145
+ label: 'Pipeline Coverage by Quarter',
87
146
  type: 'matrix',
88
- columns: [{ field: 'amount', label: 'Pipeline', aggregate: 'sum' }],
89
- groupingsDown: [{ field: 'forecast_category' }],
90
- groupingsAcross: [{ field: 'close_date', dateGranularity: 'quarter' }],
91
- }
147
+ dataset: 'opportunity_pipeline',
148
+ rows: ['forecast_category'],
149
+ columns: ['close_quarter'],
150
+ values: ['amount_sum'],
151
+ });
92
152
  ```
93
153
 
94
- `dateGranularity` accepts `day | week | month | quarter | year` and is
95
- pushed down to the server-side aggregator.
154
+ `close_quarter` is a time dimension the **dataset** declares at that grain. A
155
+ report cannot bucket a date itself: the grain a measure is valid at belongs to
156
+ the semantic layer, not to one report. A `matrix` with no `columns` degrades to
157
+ the flat grouped table.
96
158
 
97
- ### Joined (M3)
159
+ ### Joined
98
160
 
99
161
  ```ts
100
- {
162
+ import { defineReport } from '@objectstack/spec/ui';
163
+
164
+ defineReport({
101
165
  name: 'churn_signals',
102
- objectName: 'account', // container default
166
+ label: 'Customer Churn Signals',
103
167
  type: 'joined',
104
- columns: [],
105
168
  blocks: [
106
- { name: 'at_risk', type: 'summary', columns: [...], filter: {...} },
107
- { name: 'lost', type: 'summary', objectName: 'opportunity', columns: [...], filter: {...} },
169
+ {
170
+ name: 'at_risk',
171
+ label: 'At-Risk Accounts',
172
+ type: 'summary',
173
+ dataset: 'account_health',
174
+ rows: ['industry'],
175
+ values: ['account_count'],
176
+ runtimeFilter: { is_active: true },
177
+ },
178
+ {
179
+ name: 'lost',
180
+ label: 'Recently Lost',
181
+ type: 'summary',
182
+ dataset: 'opportunity_pipeline',
183
+ rows: ['owner'],
184
+ values: ['amount_sum'],
185
+ runtimeFilter: { stage: 'closed_lost' },
186
+ },
108
187
  ],
109
- }
188
+ });
110
189
  ```
111
190
 
112
- Block rules: `objectName` falls back to the container; `filter` is ANDed
113
- with the container's; each block runs an isolated `useReportData()` call;
114
- `block.type` must not be `joined` (no recursion).
191
+ Block rules: every block names its **own** `dataset` (there is no container
192
+ dataset to inherit); the container's `runtimeFilter` is merged into each block
193
+ and the block's own keys win; each block orders itself through its own `order`;
194
+ a block's declared `type` picks its presentation; `block.type` must not be
195
+ `joined` (no recursion).
115
196
 
116
- ## Server-side aggregation + drill-down
197
+ ### Ordering
198
+
199
+ `order` is a list, most significant key first, lowered onto the dataset
200
+ selection so the **server** orders the query — which is why ordering by a
201
+ derived measure works and the sort applies to the whole result rather than to
202
+ one fetched page. `by` names a dimension the report groups by or a measure it
203
+ displays. For a matrix the across axis reads left-to-right in row-arrival
204
+ order, so ordering by the across dimension is what fixes the column order;
205
+ declaring nothing still reads correctly, because a selected time dimension
206
+ defaults to ascending.
117
207
 
118
- `useReportData()` translates a `Report` into spec `QueryAST` and posts it
119
- to `POST /api/v1/data/:object/query`. If the endpoint is unavailable it
120
- falls back transparently to `dataSource.find()` + client-side aggregation.
208
+ ### Embedded chart
209
+
210
+ `chart.type` is one of the spec's chart names; `xAxis` / `yAxis` are **bare
211
+ dimension and measure names** from the report's own selection, not field paths
212
+ and not expressions:
213
+
214
+ ```ts
215
+ import { defineReport } from '@objectstack/spec/ui';
216
+
217
+ defineReport({
218
+ name: 'pipeline_by_stage',
219
+ label: 'Pipeline by Stage',
220
+ type: 'summary',
221
+ dataset: 'opportunity_pipeline',
222
+ rows: ['stage'],
223
+ values: ['amount_sum'],
224
+ chart: {
225
+ type: 'bar',
226
+ title: 'Pipeline by Stage',
227
+ xAxis: 'stage',
228
+ yAxis: 'amount_sum',
229
+ showLegend: false,
230
+ },
231
+ });
232
+ ```
233
+
234
+ `series`, `colors`, `height`, `showDataLabels`, `annotations` and `interaction`
235
+ are passed through to the chart as authored.
236
+
237
+ ## Server-side aggregation + drill-down
121
238
 
122
- Every aggregated cell dispatches a `drill` action through `ActionRunner`:
239
+ A dataset-bound report selects its dimensions (`rows`, and for `matrix` also
240
+ `columns`) and measures (`values`) **by name** and runs them through
241
+ `dataSource.queryDataset` — the same governed semantic-layer path that
242
+ dataset-bound dashboard widgets and the dataset preview use, so the numbers and
243
+ the server-resolved dimension labels match everywhere. Totals (row subtotals,
244
+ column subtotals, grand total) are **server-computed**: the renderer places the
245
+ pre-aggregated rows it is handed and never re-aggregates bucketed values
246
+ client-side, since measures like `avg` cannot be recombined without drifting
247
+ from the semantic layer.
248
+
249
+ Drill-down is a **host callback, not a registered handler**: pass `onDrill` and
250
+ every aggregated row / matrix cell becomes clickable. The report emits *what was
251
+ clicked*; the host decides where that goes, because the renderer only knows
252
+ dimension names (ADR-0021 D2).
123
253
 
124
254
  ```tsx
125
- import { registerDrillHandler } from '@object-ui/plugin-report';
126
- registerDrillHandler(actionRunner, { navigate: router.push });
255
+ import { ReportRenderer, type DatasetDrillArgs } from '@object-ui/plugin-report';
256
+
257
+ <ReportRenderer
258
+ schema={report}
259
+ dataSource={dataSource}
260
+ onDrill={(args: DatasetDrillArgs) => {
261
+ // The host resolves dataset → object and dimension → field, then navigates.
262
+ const filter = { ...args.objectFilter, ...args.runtimeFilter };
263
+ router.push(`/records/${args.object}?filter=${encodeURIComponent(JSON.stringify(filter))}`);
264
+ }}
265
+ />
127
266
  ```
128
267
 
129
- Drill targets:
130
- 1. **List view** — default; navigates to the filtered records.
131
- 2. **Report drawer** — if the host widget declares `drillDown.report`,
132
- the click opens a side drawer that renders that report scoped to the
133
- cell's group key (composes dashboard → report → record).
268
+ What a drill click carries:
269
+
270
+ | `DatasetDrillArgs` | Meaning |
271
+ | ------------------ | ------- |
272
+ | `dataset` | Dataset the clicked aggregate was computed over. |
273
+ | `groupKey` | Dimension **name** → clicked bucket value (row dims, plus across dims for a matrix cell). |
274
+ | `runtimeFilter`| The effective render-time scope filter, if any. |
275
+ | `object` | The dataset's base object, when the server supplied it. |
276
+ | `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. |
277
+
278
+ Supply no `onDrill` and nothing is clickable; a report can also opt out with
279
+ `drilldown: false`.
134
280
 
135
281
  ## Filter-time date helpers — current limitation
136
282
 
@@ -144,21 +290,76 @@ const daysAgo = (n: number): string => {
144
290
  return d.toISOString().slice(0, 10);
145
291
  };
146
292
 
147
- filter: { close_date: { $gte: daysAgo(30) } }
293
+ runtimeFilter: { close_date: { $gte: daysAgo(30) } }
148
294
  ```
149
295
 
150
296
  See the bundled CRM `customer_churn_signals` demo for the full pattern.
151
297
  Native filter-time CEL evaluation is tracked for a future major version.
152
298
 
153
- ## Legacy components
154
-
155
- `ReportBuilder`, `ReportViewer` and the export functions below remain
156
- available for legacy presentation-layer reports and are not affected by
157
- the spec-driven pipeline above.
158
-
159
-
160
-
161
- Export reports in multiple formats:
299
+ ## Stored pre-9.0 documents — migration only
300
+
301
+ > ⚠️ **This section is about documents that already exist in storage. The shape
302
+ > it describes is NOT available for authoring** — everything above is how a
303
+ > report is written, nothing here is.
304
+
305
+ Before the ADR-0021 cutover a report carried its own query: an `objectName`,
306
+ `columns` as column *definition objects* with `aggregate`, and
307
+ `groupingsDown` / `groupingsAcross` with `sortOrder` and `dateGranularity`.
308
+ That form is **rejected by the current schema** — `objectName` and
309
+ `groupingsDown` come back as unrecognized keys, and object-shaped `columns`
310
+ fails as the wrong type (`columns` is now a list of dimension names).
311
+
312
+ Stored documents in that shape still render, through a deliberately lossy
313
+ bridge: `ReportRenderer` converts them to a presentation report and hands that
314
+ to `ReportViewer`. The converter reads the *current* keys (`values`, `rows`), so
315
+ a pre-9.0 document arrives with none of them and what comes out is the report's
316
+ title over an **empty column set**. The document renders; its columns,
317
+ groupings, aggregates and sort do not, and nothing reports an error. That is why
318
+ the bridge is a waiting room rather than a supported way to write a report.
319
+
320
+ Migrating one is a re-expression against the dataset:
321
+
322
+ | Pre-9.0 key | Where it goes now |
323
+ | ---------------------------------- | ----------------------------------------------------------------------- |
324
+ | `objectName` | Gone from the report — the `dataset` owns the object. |
325
+ | `columns: [{ field, aggregate }]` | `values: string[]`; the aggregate becomes a **measure** in the dataset. |
326
+ | `columns: [{ field }]` | `rows: string[]`, as a dimension. |
327
+ | `groupingsDown: [{ field }]` | `rows: string[]`. |
328
+ | `groupingsAcross: [{ field }]` | `columns: string[]`. |
329
+ | `sortOrder` on a grouping | `order: [{ by, direction }]` on the report. |
330
+ | `dateGranularity` on a grouping | A time dimension declared at that grain **in the dataset**. |
331
+ | `filter` | `runtimeFilter`. |
332
+
333
+ The part with no mechanical equivalent is the dataset itself: measures and time
334
+ dimensions the old report declared inline must exist in the semantic layer
335
+ before the migrated report can name them.
336
+
337
+ The query-form renderers that used to draw these documents inline
338
+ (`SpecReportGrid`, `MatrixRenderer`, `JoinedReportRenderer`) were removed at the
339
+ cutover and have no replacement export — the bridge above is what renders a
340
+ stored old-shape document now.
341
+
342
+ ## Legacy presentation layer
343
+
344
+ `ReportViewer`, `LegacyReportRenderer` and the export functions below remain
345
+ available for pre-spec presentation reports (`{ report, data, columns, chart }`)
346
+ and are not affected by the dataset-bound pipeline above.
347
+
348
+ The authoring components that used to sit alongside them — `ReportBuilder`,
349
+ `ScheduleConfig`, `ChartConfig`, the columns/groupings editors — and the
350
+ `registerDrillHandler`-style drill helpers were **removed** in the 9.0 cutover
351
+ (see `CHANGELOG.md`). They have no replacement export in this package: report
352
+ authoring lives in the console designer, and drill-down is the `onDrill`
353
+ callback above.
354
+
355
+ ### Export
356
+
357
+ Every format exporter takes the **report schema first and the rows second** —
358
+ `(report: ReportComponentSchema, data: any[], config?: ReportExportConfig)`. The
359
+ schema is where the exporter reads the columns (`report.fields`) and the download
360
+ name (`config.filename`, else `report.title`) from; there is **no filename
361
+ parameter**. All five return `void` synchronously — they trigger a browser
362
+ download — so there is nothing to `await`:
162
363
 
163
364
  ```tsx
164
365
  import {
@@ -169,51 +370,151 @@ import {
169
370
  exportAsPDF,
170
371
  exportAsExcel,
171
372
  } from '@object-ui/plugin-report';
373
+ import type { ReportComponentSchema } from '@object-ui/types';
374
+
375
+ const report: ReportComponentSchema = {
376
+ type: 'report',
377
+ title: 'Sales Report',
378
+ fields: [
379
+ { name: 'account', label: 'Account' },
380
+ { name: 'amount', label: 'Amount', type: 'currency', aggregation: 'sum' },
381
+ ],
382
+ };
383
+ const rows = [{ account: 'Acme', amount: 12000 }];
384
+
385
+ exportAsCSV(report, rows); // downloads "Sales Report.csv"
386
+ exportAsJSON(report, rows); // "Sales Report.json"
387
+ exportAsHTML(report, rows); // "Sales Report.html"
388
+ exportAsPDF(report, rows); // opens a print window; falls back to .html if blocked
389
+ exportAsExcel(report, rows, { format: 'excel', filename: 'sales-2026-q1.tsv' });
172
390
 
173
- await exportAsCSV(reportData, 'sales-report.csv');
174
- await exportAsPDF(reportData, 'sales-report.pdf');
175
- await exportAsExcel(reportData, 'sales-report.xlsx');
391
+ // `exportReport` is the router — the format comes FIRST, then the same triple.
392
+ exportReport('csv', report, rows);
176
393
  ```
177
394
 
395
+ `exportAsExcel` writes a BOM-prefixed **TSV** that Excel opens, so its default
396
+ name is `report.title` + `.tsv`, not `.xlsx`. `config.includeHeaders: false` drops the
397
+ header row; `orientation` / `pageSize` apply to the HTML and PDF paths only.
398
+
178
399
  ### Live Export
179
400
 
180
- Export with real-time data and Excel formulas:
401
+ `exportWithLiveData` fetches the rows itself, so `dataSource` and `resource` are
402
+ **required** in `LiveExportOptions` — an options object carrying only `format`
403
+ does not compile. It is the one `async` export function, resolving to a
404
+ `LiveExportResult`:
181
405
 
182
406
  ```tsx
183
- import { exportWithLiveData, exportExcelWithFormulas } from '@object-ui/plugin-report';
407
+ import { exportWithLiveData } from '@object-ui/plugin-report';
184
408
 
185
- await exportWithLiveData(reportConfig, { format: 'pdf' });
186
- await exportExcelWithFormulas(reportConfig, {
187
- columns: [{ field: 'total', formula: 'SUM(B2:B100)' }],
409
+ // `myAdapter` is a host-provided DataSource (see @object-ui/data-*).
410
+ const result = await exportWithLiveData(report, {
411
+ dataSource: myAdapter,
412
+ resource: 'orders',
413
+ format: 'pdf',
188
414
  });
415
+ // -> { success: true, recordCount: 42, format: 'pdf' }
189
416
  ```
190
417
 
191
- ### ScheduleConfig
418
+ The format falls back to `report.defaultExportFormat`, then `'pdf'`. Failures are
419
+ returned, not thrown: `{ success: false, recordCount: 0, format, error }`.
192
420
 
193
- Configure recurring report generation:
421
+ `exportExcelWithFormulas` is a separate, synchronous three-parameter function —
422
+ `(report, data, options)` — with the column list inside the third argument. A
423
+ column is `{ name, header, width?, numberFormat?, formula? }`: `name` is the key
424
+ read off each row (there is no `field` key), `header` is required, and a `formula`
425
+ is a template in which the `{ROW}` placeholder is replaced by that row's
426
+ spreadsheet line number:
194
427
 
195
428
  ```tsx
196
- import { ScheduleConfig, createScheduleTrigger } from '@object-ui/plugin-report';
429
+ import { exportExcelWithFormulas } from '@object-ui/plugin-report';
197
430
 
198
- <ScheduleConfig
199
- reportId="monthly-sales"
200
- onSave={(schedule) => saveSchedule(schedule)}
201
- />
431
+ exportExcelWithFormulas(report, rows, {
432
+ columns: [
433
+ { name: 'amount', header: 'Amount', numberFormat: '#,##0.00' },
434
+ { name: 'total', header: 'Total', formula: '=B{ROW}*C{ROW}' },
435
+ ],
436
+ includeAggregationRow: true,
437
+ });
438
+ ```
439
+
440
+ Omitting `columns` derives them from `report.fields`. `includeAggregationRow`
441
+ appends a totals row built from each field's `aggregation`.
442
+
443
+ ### Scheduled export
444
+
445
+ A schedule is **data on the report schema**, not a component: it is
446
+ `ReportComponentSchema.schedule`, typed `ReportScheduleConfig`. Both types live
447
+ in `@object-ui/types` and are *not* re-exported here — importing them from this
448
+ package is a TS2305.
449
+
450
+ `createScheduleTrigger` turns that declared schedule into a callable a workflow
451
+ engine can invoke. It takes the report, the data source, the resource to query
452
+ and a completion callback:
202
453
 
203
- const trigger = createScheduleTrigger((reportId) => generateReport(reportId));
454
+ ```ts
455
+ import { createScheduleTrigger } from '@object-ui/plugin-report';
456
+ import type { ReportComponentSchema, ReportScheduleConfig } from '@object-ui/types';
457
+
458
+ // `dataSource` and `notify` are host-provided.
459
+
460
+ const report: ReportComponentSchema = {
461
+ type: 'report',
462
+ title: 'Monthly Sales',
463
+ schedule: {
464
+ enabled: true,
465
+ frequency: 'monthly',
466
+ dayOfMonth: 1,
467
+ time: '07:00',
468
+ formats: ['pdf', 'excel'],
469
+ recipients: ['sales@example.com'],
470
+ },
471
+ };
472
+
473
+ const trigger = createScheduleTrigger(
474
+ report,
475
+ dataSource,
476
+ 'orders',
477
+ (exported: ReportComponentSchema, schedule: ReportScheduleConfig) => {
478
+ notify(schedule.recipients ?? [], schedule.subject ?? exported.title);
479
+ },
480
+ );
481
+
482
+ const results = await trigger(); // LiveExportResult[] — one entry per scheduled format
204
483
  ```
205
484
 
485
+ `trigger()` reads `report.schedule` itself: a schedule that is missing or
486
+ `enabled: false` exports nothing and resolves to `[]`. Otherwise it exports once
487
+ per entry in `schedule.formats` (falling back to `report.defaultExportFormat`,
488
+ then `'pdf'`) and calls the completion callback with the report and the schedule
489
+ it ran.
490
+
206
491
  ### Schema-Driven Usage
207
492
 
208
- Components auto-register with `ComponentRegistry`:
493
+ Importing the package registers three component types with `ComponentRegistry`:
494
+
495
+ | `type` | Component | Notes |
496
+ | --------------- | ---------------- | -------------------------------------------------- |
497
+ | `report` | `ReportRenderer` | The dispatcher. |
498
+ | `spec-report` | `ReportRenderer` | Spec-native alias; the report goes under `report`. |
499
+ | `report-viewer` | `ReportViewer` | Presentation-layer viewer. |
209
500
 
210
501
  ```json
211
502
  {
212
- "type": "report-builder",
213
- "report": { "sections": [] }
503
+ "type": "spec-report",
504
+ "report": {
505
+ "name": "opp_by_stage",
506
+ "type": "summary",
507
+ "dataset": "opportunity_pipeline",
508
+ "rows": ["stage"],
509
+ "values": ["amount_sum"]
510
+ }
214
511
  }
215
512
  ```
216
513
 
514
+ There is no `report-builder` component type — the authoring component that name
515
+ addressed was removed in the 9.0 cutover, so a node declaring it resolves to
516
+ nothing.
517
+
217
518
  ### Type-aware cell rendering
218
519
 
219
520
  `ReportViewer` delegates cell rendering to the shared `getCellRenderer`
@@ -237,40 +538,11 @@ component appropriate for its type — instead of `String(value)`.
237
538
  | `richtext` / `html` / `markdown` | Sanitised inline content |
238
539
  | `json` | Collapsed code preview |
239
540
 
240
- Authors do **not** need to repeat type metadata on every report column:
241
- when a report binds an `objectName`, the runtime auto-hydrates each
242
- column's `type`, `options`, `referenceTo`, and `label` from the
243
- corresponding `ObjectField`. Author-provided values always win.
244
-
245
- Minimal report leveraging type-aware cells:
246
-
247
- ```ts
248
- import type { ReportInput } from '@objectstack/spec/ui';
249
-
250
- export const ContactsReport: ReportInput = {
251
- name: 'contacts_by_account',
252
- label: 'Contacts by Account',
253
- objectName: 'contact', // ← enables auto-hydration
254
- type: 'tabular',
255
- columns: [
256
- { field: 'full_name', label: 'Name' },
257
- { field: 'email', label: 'Email' }, // → mailto:
258
- { field: 'phone', label: 'Phone' }, // → tel:
259
- { field: 'is_primary', label: 'Primary' }, // → ✓/✗
260
- { field: 'account', label: 'Account' }, // → linked record
261
- { field: 'status', label: 'Status' }, // → Badge with option color
262
- ],
263
- };
264
- ```
265
-
266
- Override per column when needed:
267
-
268
- ```ts
269
- columns: [
270
- { field: 'tier', label: 'Tier', type: 'select',
271
- options: [{ value: 'gold', label: 'Gold', color: 'amber' }] },
272
- ]
273
- ```
541
+ These are presentation-layer columns: each entry in `report.fields` carries its
542
+ own `name`, `label` and `type`, and that declared `type` is what selects the
543
+ renderer. This package resolves nothing from object metadata — there is no
544
+ `objectName` on a report to hydrate from, and a column with no `type` renders
545
+ as plain text.
274
546
 
275
547
  Legacy `renderAs: 'badge'` + `colorMap` is still honoured for plain
276
548
  string columns.