@object-ui/plugin-report 17.3.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
 
@@ -23,27 +23,58 @@ npm install @object-ui/plugin-report
23
23
  **Peer Dependencies:**
24
24
  - `react` ^18.0.0 || ^19.0.0
25
25
  - `react-dom` ^18.0.0 || ^19.0.0
26
- - `@object-ui/core`
27
26
 
28
27
  ## Quick Start
29
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
+
30
34
  ```tsx
31
- import { ReportBuilder, ReportViewer, ReportRenderer } from '@object-ui/plugin-report';
35
+ import { ReportRenderer } from '@object-ui/plugin-report';
32
36
 
33
- function ReportEditorPage() {
34
- 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
+ );
35
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`:
36
57
 
37
- function ReportViewPage() {
38
- return <ReportViewer report={reportDefinition} showToolbar />;
58
+ ```tsx
59
+ import { DatasetReportRenderer, isDatasetReport } from '@object-ui/plugin-report';
60
+
61
+ if (isDatasetReport(stored)) {
62
+ return <DatasetReportRenderer report={stored} dataSource={dataSource} />;
39
63
  }
64
+ ```
40
65
 
41
- function EmbeddedReport() {
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:
69
+
70
+ ```tsx
71
+ import { ReportViewer } from '@object-ui/plugin-report';
72
+
73
+ function LegacyReportPage() {
42
74
  return (
43
- <ReportRenderer
44
- title="Monthly Sales"
45
- description="Sales performance overview"
46
- chart={chartConfig}
75
+ <ReportViewer
76
+ schema={{ type: 'report-viewer', report: reportDefinition, data: rows, showToolbar: true }}
77
+ onRefresh={refetch}
47
78
  />
48
79
  );
49
80
  }
@@ -51,87 +82,201 @@ function EmbeddedReport() {
51
82
 
52
83
  ## Spec Reports — the four variants
53
84
 
54
- 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`:
55
91
 
56
92
  ```ts
57
- import type { ReportInput } from '@objectstack/spec/ui';
93
+ import { defineReport } from '@objectstack/spec/ui';
58
94
  import { ReportRenderer } from '@object-ui/plugin-report';
59
95
 
60
- const report: ReportInput = {
96
+ const report = defineReport({
61
97
  name: 'opp_by_stage',
62
- objectName: 'opportunity',
98
+ label: 'Opportunities by Stage',
63
99
  type: 'summary',
64
- columns: [
65
- { field: 'stage' },
66
- { field: 'amount', aggregate: 'sum' },
67
- { field: 'id', label: 'Deals', aggregate: 'count' },
68
- ],
69
- groupingsDown: [{ field: 'stage', sortOrder: 'asc' }],
70
- };
100
+ dataset: 'opportunity_pipeline',
101
+ rows: ['stage'],
102
+ values: ['amount_sum', 'deal_count'],
103
+ order: [{ by: 'amount_sum', direction: 'desc' }],
104
+ });
71
105
 
72
106
  <ReportRenderer schema={report} dataSource={ds} />
73
107
  ```
74
108
 
75
- | `type` | Description |
76
- | --------- | ---------------------------------------------------- |
77
- | `tabular` | Flat record list |
78
- | `summary` | Single-axis grouped + aggregated |
79
- | `matrix` | Row × column pivot with cell aggregates and totals |
80
- | `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.
81
135
 
82
136
  ### Matrix (row × column pivot)
83
137
 
138
+ `columns` is a second list of dimension **names**, used as the across axis:
139
+
84
140
  ```ts
85
- {
141
+ import { defineReport } from '@objectstack/spec/ui';
142
+
143
+ defineReport({
86
144
  name: 'pipeline_by_quarter',
87
- objectName: 'opportunity',
145
+ label: 'Pipeline Coverage by Quarter',
88
146
  type: 'matrix',
89
- columns: [{ field: 'amount', label: 'Pipeline', aggregate: 'sum' }],
90
- groupingsDown: [{ field: 'forecast_category' }],
91
- groupingsAcross: [{ field: 'close_date', dateGranularity: 'quarter' }],
92
- }
147
+ dataset: 'opportunity_pipeline',
148
+ rows: ['forecast_category'],
149
+ columns: ['close_quarter'],
150
+ values: ['amount_sum'],
151
+ });
93
152
  ```
94
153
 
95
- `dateGranularity` accepts `day | week | month | quarter | year` and is
96
- 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.
97
158
 
98
- ### Joined (M3)
159
+ ### Joined
99
160
 
100
161
  ```ts
101
- {
162
+ import { defineReport } from '@objectstack/spec/ui';
163
+
164
+ defineReport({
102
165
  name: 'churn_signals',
103
- objectName: 'account', // container default
166
+ label: 'Customer Churn Signals',
104
167
  type: 'joined',
105
- columns: [],
106
168
  blocks: [
107
- { name: 'at_risk', type: 'summary', columns: [...], filter: {...} },
108
- { 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
+ },
109
187
  ],
110
- }
188
+ });
111
189
  ```
112
190
 
113
- Block rules: `objectName` falls back to the container; `filter` is ANDed
114
- with the container's; each block runs an isolated `useReportData()` call;
115
- `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).
116
196
 
117
- ## 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.
207
+
208
+ ### Embedded chart
118
209
 
119
- `useReportData()` translates a `Report` into spec `QueryAST` and posts it
120
- to `POST /api/v1/data/:object/query`. If the endpoint is unavailable it
121
- falls back transparently to `dataSource.find()` + client-side aggregation.
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:
122
213
 
123
- Every aggregated cell dispatches a `drill` action through `ActionRunner`:
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
238
+
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).
124
253
 
125
254
  ```tsx
126
- import { registerDrillHandler } from '@object-ui/plugin-report';
127
- 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
+ />
128
266
  ```
129
267
 
130
- Drill targets:
131
- 1. **List view** — default; navigates to the filtered records.
132
- 2. **Report drawer** — if the host widget declares `drillDown.report`,
133
- the click opens a side drawer that renders that report scoped to the
134
- 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`.
135
280
 
136
281
  ## Filter-time date helpers — current limitation
137
282
 
@@ -145,21 +290,76 @@ const daysAgo = (n: number): string => {
145
290
  return d.toISOString().slice(0, 10);
146
291
  };
147
292
 
148
- filter: { close_date: { $gte: daysAgo(30) } }
293
+ runtimeFilter: { close_date: { $gte: daysAgo(30) } }
149
294
  ```
150
295
 
151
296
  See the bundled CRM `customer_churn_signals` demo for the full pattern.
152
297
  Native filter-time CEL evaluation is tracked for a future major version.
153
298
 
154
- ## Legacy components
155
-
156
- `ReportBuilder`, `ReportViewer` and the export functions below remain
157
- available for legacy presentation-layer reports and are not affected by
158
- the spec-driven pipeline above.
159
-
160
-
161
-
162
- 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`:
163
363
 
164
364
  ```tsx
165
365
  import {
@@ -170,51 +370,151 @@ import {
170
370
  exportAsPDF,
171
371
  exportAsExcel,
172
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' });
173
390
 
174
- await exportAsCSV(reportData, 'sales-report.csv');
175
- await exportAsPDF(reportData, 'sales-report.pdf');
176
- await exportAsExcel(reportData, 'sales-report.xlsx');
391
+ // `exportReport` is the router — the format comes FIRST, then the same triple.
392
+ exportReport('csv', report, rows);
177
393
  ```
178
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
+
179
399
  ### Live Export
180
400
 
181
- 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`:
182
405
 
183
406
  ```tsx
184
- import { exportWithLiveData, exportExcelWithFormulas } from '@object-ui/plugin-report';
407
+ import { exportWithLiveData } from '@object-ui/plugin-report';
185
408
 
186
- await exportWithLiveData(reportConfig, { format: 'pdf' });
187
- await exportExcelWithFormulas(reportConfig, {
188
- 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',
189
414
  });
415
+ // -> { success: true, recordCount: 42, format: 'pdf' }
190
416
  ```
191
417
 
192
- ### ScheduleConfig
418
+ The format falls back to `report.defaultExportFormat`, then `'pdf'`. Failures are
419
+ returned, not thrown: `{ success: false, recordCount: 0, format, error }`.
193
420
 
194
- 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:
195
427
 
196
428
  ```tsx
197
- import { ScheduleConfig, createScheduleTrigger } from '@object-ui/plugin-report';
429
+ import { exportExcelWithFormulas } from '@object-ui/plugin-report';
198
430
 
199
- <ScheduleConfig
200
- reportId="monthly-sales"
201
- onSave={(schedule) => saveSchedule(schedule)}
202
- />
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`.
203
442
 
204
- const trigger = createScheduleTrigger((reportId) => generateReport(reportId));
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:
453
+
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
205
483
  ```
206
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
+
207
491
  ### Schema-Driven Usage
208
492
 
209
- 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. |
210
500
 
211
501
  ```json
212
502
  {
213
- "type": "report-builder",
214
- "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
+ }
215
511
  }
216
512
  ```
217
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
+
218
518
  ### Type-aware cell rendering
219
519
 
220
520
  `ReportViewer` delegates cell rendering to the shared `getCellRenderer`
@@ -238,55 +538,15 @@ component appropriate for its type — instead of `String(value)`.
238
538
  | `richtext` / `html` / `markdown` | Sanitised inline content |
239
539
  | `json` | Collapsed code preview |
240
540
 
241
- Authors do **not** need to repeat type metadata on every report column:
242
- when a report binds an `objectName`, the runtime auto-hydrates each
243
- column's `type`, `options`, `referenceTo`, and `label` from the
244
- corresponding `ObjectField`. Author-provided values always win.
245
-
246
- Minimal report leveraging type-aware cells:
247
-
248
- ```ts
249
- import type { ReportInput } from '@objectstack/spec/ui';
250
-
251
- export const ContactsReport: ReportInput = {
252
- name: 'contacts_by_account',
253
- label: 'Contacts by Account',
254
- objectName: 'contact', // ← enables auto-hydration
255
- type: 'tabular',
256
- columns: [
257
- { field: 'full_name', label: 'Name' },
258
- { field: 'email', label: 'Email' }, // → mailto:
259
- { field: 'phone', label: 'Phone' }, // → tel:
260
- { field: 'is_primary', label: 'Primary' }, // → ✓/✗
261
- { field: 'account', label: 'Account' }, // → linked record
262
- { field: 'status', label: 'Status' }, // → Badge with option color
263
- ],
264
- };
265
- ```
266
-
267
- Override per column when needed:
268
-
269
- ```ts
270
- columns: [
271
- { field: 'tier', label: 'Tier', type: 'select',
272
- options: [{ value: 'gold', label: 'Gold', color: 'amber' }] },
273
- ]
274
- ```
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.
275
546
 
276
547
  Legacy `renderAs: 'badge'` + `colorMap` is still honoured for plain
277
548
  string columns.
278
549
 
279
- <!-- release-metadata:v3.3.0 -->
280
-
281
- ## Compatibility
282
-
283
- - **React:** 18.x or 19.x
284
- - **Node.js:** ≥ 18
285
- - **TypeScript:** ≥ 5.0 (strict mode)
286
- - **`@objectstack/spec`:** ^3.3.0
287
- - **`@objectstack/client`:** ^3.3.0
288
- - **Tailwind CSS:** ≥ 3.4 (for packages with UI)
289
-
290
550
  ## Links
291
551
 
292
552
  - 📚 [Documentation](https://www.objectui.org/docs/plugins/plugin-report)