@object-ui/plugin-report 17.5.0 → 17.7.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,18 +1,18 @@
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
- - 📦 **Auto-registered** — components register with `ComponentRegistry` on import; embed via `{ "type": "spec-report", "report": {...} }`
15
+ - 📦 **Auto-registered** — components register with `ComponentRegistry` on import; embed via `{ "type": "report", "report": {...} }`
16
16
 
17
17
  ## Installation
18
18
 
@@ -26,23 +26,73 @@ 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';
36
+ import type { DataSource } from '@object-ui/types';
37
+
38
+ // `dataSource` and `router` are host-provided.
39
+ declare const dataSource: DataSource;
40
+ declare const router: { push: (href: string) => void };
31
41
 
32
- function ReportEditorPage() {
33
- return <ReportBuilder report={initialReport} />;
42
+ function ReportPage() {
43
+ return (
44
+ <ReportRenderer
45
+ schema={{
46
+ name: 'opp_by_stage',
47
+ type: 'summary',
48
+ dataset: 'opportunity_pipeline',
49
+ rows: ['stage'],
50
+ values: ['amount_sum', 'deal_count'],
51
+ }}
52
+ dataSource={dataSource}
53
+ onDrill={({ object }) => router.push(`/records/${object}`)}
54
+ />
55
+ );
34
56
  }
57
+ ```
58
+
59
+ The dataset-bound renderer can also be addressed directly when the host has
60
+ already decided the shape — it takes the report as `report`, not `schema`:
35
61
 
36
- function ReportViewPage() {
37
- return <ReportViewer report={reportDefinition} showToolbar />;
62
+ ```tsx
63
+ import { DatasetReportRenderer, isDatasetReport } from '@object-ui/plugin-report';
64
+ import type { DataSource } from '@object-ui/types';
65
+
66
+ // `stored` is whatever the host loaded; `dataSource` is host-provided.
67
+ declare const stored: unknown;
68
+ declare const dataSource: DataSource;
69
+
70
+ function StoredReportPage() {
71
+ if (isDatasetReport(stored)) {
72
+ return <DatasetReportRenderer report={stored} dataSource={dataSource} />;
73
+ }
74
+ return null;
38
75
  }
76
+ ```
77
+
78
+ `ReportViewer` renders a presentation-layer report, and takes a whole
79
+ `ReportViewerSchema` as `schema` — `report` / `showToolbar` are keys *inside*
80
+ that schema, not props of the component:
39
81
 
40
- function EmbeddedReport() {
82
+ ```tsx
83
+ import { ReportViewer } from '@object-ui/plugin-report';
84
+ import type { ReportComponentSchema } from '@object-ui/types';
85
+
86
+ // All three are host-provided.
87
+ declare const reportDefinition: ReportComponentSchema;
88
+ declare const rows: Array<Record<string, unknown>>;
89
+ declare const refetch: () => void;
90
+
91
+ function LegacyReportPage() {
41
92
  return (
42
- <ReportRenderer
43
- title="Monthly Sales"
44
- description="Sales performance overview"
45
- chart={chartConfig}
93
+ <ReportViewer
94
+ schema={{ type: 'report-viewer', report: reportDefinition, data: rows, showToolbar: true }}
95
+ onRefresh={refetch}
46
96
  />
47
97
  );
48
98
  }
@@ -50,87 +100,216 @@ function EmbeddedReport() {
50
100
 
51
101
  ## Spec Reports — the four variants
52
102
 
53
- The plugin renders any `Report` defined by `@objectstack/spec`:
103
+ A report is **dataset-bound**: it names a semantic-layer `dataset` and selects
104
+ that dataset's measures (`values`) grouped by its dimensions (`rows`, and for a
105
+ matrix also `columns`). Every name in a report is a name the dataset defines —
106
+ the report declares no object, no field, no aggregate and no date bucket of its
107
+ own. `defineReport` validates at authoring time and returns the parsed shape
108
+ `ReportRenderer` takes as `schema`:
54
109
 
55
- ```ts
56
- import type { ReportInput } from '@objectstack/spec/ui';
110
+ ```tsx
111
+ import { defineReport } from '@objectstack/spec/ui';
57
112
  import { ReportRenderer } from '@object-ui/plugin-report';
113
+ import type { DataSource } from '@object-ui/types';
58
114
 
59
- const report: ReportInput = {
115
+ // `ds` is host-provided.
116
+ declare const ds: DataSource;
117
+
118
+ const report = defineReport({
60
119
  name: 'opp_by_stage',
61
- objectName: 'opportunity',
120
+ label: 'Opportunities by Stage',
62
121
  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
- };
122
+ dataset: 'opportunity_pipeline',
123
+ rows: ['stage'],
124
+ values: ['amount_sum', 'deal_count'],
125
+ order: [{ by: 'amount_sum', direction: 'desc' }],
126
+ });
70
127
 
71
128
  <ReportRenderer schema={report} dataSource={ds} />
72
129
  ```
73
130
 
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 |
131
+ | Key | Type | Meaning |
132
+ | --------------- | ---------------------------------------------- | ---------------------------------------------------------------- |
133
+ | `name` | `string` (required) | Identifier, at least 2 characters. |
134
+ | `label` | `string` \| `Record< string, string >` (required) | Display title; the record form is the i18n shape. |
135
+ | `type` | `tabular` \| `summary` \| `matrix` \| `joined` | Defaults to `tabular`. |
136
+ | `dataset` | `string` | The dataset this report reads. |
137
+ | `rows` | `string[]` | Dimension **names** to group down. |
138
+ | `columns` | `string[]` | Dimension **names** across — the matrix pivot axis. |
139
+ | `values` | `string[]` | Measure **names** to display. |
140
+ | `runtimeFilter` | filter condition | Scope filter, merged with the filter the host passes at render. |
141
+ | `order` | `{ by, direction }[]` | Result ordering, most significant key first. |
142
+ | `drilldown` | `boolean` | Defaults to `true`. |
143
+ | `chart` | object | Embedded visualization over the same selection. |
144
+ | `blocks` | report[] | `joined` only — the stacked sub-reports. |
145
+
146
+ The schema is **strict**: a key it does not declare is rejected, not ignored.
147
+
148
+ | `type` | Description |
149
+ | --------- | ------------------------------------------------------------------------------ |
150
+ | `tabular` | The selection as a flat list — `rows` + `values`, no totals |
151
+ | `summary` | The same table plus a server-computed grand-total footer |
152
+ | `matrix` | Cross-tab: `rows` down × `columns` across, measures in the cells, plus subtotals |
153
+ | `joined` | A vertical stack of blocks, each its own dataset-bound table |
154
+
155
+ The **declared type** picks the presentation — never the shape of the data that
156
+ comes back.
80
157
 
81
158
  ### Matrix (row × column pivot)
82
159
 
160
+ `columns` is a second list of dimension **names**, used as the across axis:
161
+
83
162
  ```ts
84
- {
163
+ import { defineReport } from '@objectstack/spec/ui';
164
+
165
+ defineReport({
85
166
  name: 'pipeline_by_quarter',
86
- objectName: 'opportunity',
167
+ label: 'Pipeline Coverage by Quarter',
87
168
  type: 'matrix',
88
- columns: [{ field: 'amount', label: 'Pipeline', aggregate: 'sum' }],
89
- groupingsDown: [{ field: 'forecast_category' }],
90
- groupingsAcross: [{ field: 'close_date', dateGranularity: 'quarter' }],
91
- }
169
+ dataset: 'opportunity_pipeline',
170
+ rows: ['forecast_category'],
171
+ columns: ['close_quarter'],
172
+ values: ['amount_sum'],
173
+ });
92
174
  ```
93
175
 
94
- `dateGranularity` accepts `day | week | month | quarter | year` and is
95
- pushed down to the server-side aggregator.
176
+ `close_quarter` is a time dimension the **dataset** declares at that grain. A
177
+ report cannot bucket a date itself: the grain a measure is valid at belongs to
178
+ the semantic layer, not to one report. A `matrix` with no `columns` degrades to
179
+ the flat grouped table.
96
180
 
97
- ### Joined (M3)
181
+ ### Joined
98
182
 
99
183
  ```ts
100
- {
184
+ import { defineReport } from '@objectstack/spec/ui';
185
+
186
+ defineReport({
101
187
  name: 'churn_signals',
102
- objectName: 'account', // container default
188
+ label: 'Customer Churn Signals',
103
189
  type: 'joined',
104
- columns: [],
105
190
  blocks: [
106
- { name: 'at_risk', type: 'summary', columns: [...], filter: {...} },
107
- { name: 'lost', type: 'summary', objectName: 'opportunity', columns: [...], filter: {...} },
191
+ {
192
+ name: 'at_risk',
193
+ label: 'At-Risk Accounts',
194
+ type: 'summary',
195
+ dataset: 'account_health',
196
+ rows: ['industry'],
197
+ values: ['account_count'],
198
+ runtimeFilter: { is_active: true },
199
+ },
200
+ {
201
+ name: 'lost',
202
+ label: 'Recently Lost',
203
+ type: 'summary',
204
+ dataset: 'opportunity_pipeline',
205
+ rows: ['owner'],
206
+ values: ['amount_sum'],
207
+ runtimeFilter: { stage: 'closed_lost' },
208
+ },
108
209
  ],
109
- }
210
+ });
110
211
  ```
111
212
 
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).
213
+ Block rules: every block names its **own** `dataset` (there is no container
214
+ dataset to inherit); the container's `runtimeFilter` is merged into each block
215
+ and the block's own keys win; each block orders itself through its own `order`;
216
+ a block's declared `type` picks its presentation; `block.type` must not be
217
+ `joined` (no recursion).
115
218
 
116
- ## Server-side aggregation + drill-down
219
+ ### Ordering
220
+
221
+ `order` is a list, most significant key first, lowered onto the dataset
222
+ selection so the **server** orders the query — which is why ordering by a
223
+ derived measure works and the sort applies to the whole result rather than to
224
+ one fetched page. `by` names a dimension the report groups by or a measure it
225
+ displays. For a matrix the across axis reads left-to-right in row-arrival
226
+ order, so ordering by the across dimension is what fixes the column order;
227
+ declaring nothing still reads correctly, because a selected time dimension
228
+ defaults to ascending.
117
229
 
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.
230
+ ### Embedded chart
121
231
 
122
- Every aggregated cell dispatches a `drill` action through `ActionRunner`:
232
+ `chart.type` is one of the spec's chart names; `xAxis` / `yAxis` are **bare
233
+ dimension and measure names** from the report's own selection, not field paths
234
+ and not expressions:
235
+
236
+ ```ts
237
+ import { defineReport } from '@objectstack/spec/ui';
238
+
239
+ defineReport({
240
+ name: 'pipeline_by_stage',
241
+ label: 'Pipeline by Stage',
242
+ type: 'summary',
243
+ dataset: 'opportunity_pipeline',
244
+ rows: ['stage'],
245
+ values: ['amount_sum'],
246
+ chart: {
247
+ type: 'bar',
248
+ title: 'Pipeline by Stage',
249
+ xAxis: 'stage',
250
+ yAxis: 'amount_sum',
251
+ showLegend: false,
252
+ },
253
+ });
254
+ ```
255
+
256
+ `series`, `colors`, `height`, `showDataLabels`, `annotations` and `interaction`
257
+ are passed through to the chart as authored.
258
+
259
+ The chart runs its own `xAxis` × `yAxis` dataset query, so it binds the same way
260
+ on every non-`joined` type. It draws above a `tabular` or `summary` table, and
261
+ below the cross-tab of a `matrix` that declares `columns`; there `xAxis` may name
262
+ a `rows` or a `columns` dimension.
263
+
264
+ ## Server-side aggregation + drill-down
265
+
266
+ A dataset-bound report selects its dimensions (`rows`, and for `matrix` also
267
+ `columns`) and measures (`values`) **by name** and runs them through
268
+ `dataSource.queryDataset` — the same governed semantic-layer path that
269
+ dataset-bound dashboard widgets and the dataset preview use, so the numbers and
270
+ the server-resolved dimension labels match everywhere. Totals (row subtotals,
271
+ column subtotals, grand total) are **server-computed**: the renderer places the
272
+ pre-aggregated rows it is handed and never re-aggregates bucketed values
273
+ client-side, since measures like `avg` cannot be recombined without drifting
274
+ from the semantic layer.
275
+
276
+ Drill-down is a **host callback, not a registered handler**: pass `onDrill` and
277
+ every aggregated row / matrix cell becomes clickable. The report emits *what was
278
+ clicked*; the host decides where that goes, because the renderer only knows
279
+ dimension names (ADR-0021 D2).
123
280
 
124
281
  ```tsx
125
- import { registerDrillHandler } from '@object-ui/plugin-report';
126
- registerDrillHandler(actionRunner, { navigate: router.push });
282
+ import { ReportRenderer, type DatasetDrillArgs } from '@object-ui/plugin-report';
283
+ import type { DataSource, SpecReport } from '@object-ui/types';
284
+
285
+ // `report`, `dataSource` and `router` are host-provided.
286
+ declare const report: SpecReport;
287
+ declare const dataSource: DataSource;
288
+ declare const router: { push: (href: string) => void };
289
+
290
+ <ReportRenderer
291
+ schema={report}
292
+ dataSource={dataSource}
293
+ onDrill={(args: DatasetDrillArgs) => {
294
+ // The host resolves dataset → object and dimension → field, then navigates.
295
+ const filter = { ...args.objectFilter, ...args.runtimeFilter };
296
+ router.push(`/records/${args.object}?filter=${encodeURIComponent(JSON.stringify(filter))}`);
297
+ }}
298
+ />
127
299
  ```
128
300
 
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).
301
+ What a drill click carries:
302
+
303
+ | `DatasetDrillArgs` | Meaning |
304
+ | ------------------ | ------- |
305
+ | `dataset` | Dataset the clicked aggregate was computed over. |
306
+ | `groupKey` | Dimension **name** → clicked bucket value (row dims, plus across dims for a matrix cell). |
307
+ | `runtimeFilter`| The effective render-time scope filter, if any. |
308
+ | `object` | The dataset's base object, when the server supplied it. |
309
+ | `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. |
310
+
311
+ Supply no `onDrill` and nothing is clickable; a report can also opt out with
312
+ `drilldown: false`.
134
313
 
135
314
  ## Filter-time date helpers — current limitation
136
315
 
@@ -138,27 +317,92 @@ The server does **not** currently evaluate `` cel`...` `` expressions
138
317
  embedded in filter values. Use module-load ISO strings instead:
139
318
 
140
319
  ```ts
320
+ import { defineReport } from '@objectstack/spec/ui';
321
+
141
322
  const daysAgo = (n: number): string => {
142
323
  const d = new Date();
143
324
  d.setUTCDate(d.getUTCDate() - n);
144
325
  return d.toISOString().slice(0, 10);
145
326
  };
146
327
 
147
- filter: { close_date: { $gte: daysAgo(30) } }
328
+ defineReport({
329
+ name: 'recent_pipeline',
330
+ label: 'Recent Pipeline',
331
+ type: 'summary',
332
+ dataset: 'opportunity_pipeline',
333
+ rows: ['stage'],
334
+ values: ['amount_sum'],
335
+ runtimeFilter: { close_date: { $gte: daysAgo(30) } },
336
+ });
148
337
  ```
149
338
 
150
339
  See the bundled CRM `customer_churn_signals` demo for the full pattern.
151
340
  Native filter-time CEL evaluation is tracked for a future major version.
152
341
 
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:
342
+ ## Stored pre-9.0 documents — migration only
343
+
344
+ > ⚠️ **This section is about documents that already exist in storage. The shape
345
+ > it describes is NOT available for authoring** — everything above is how a
346
+ > report is written, nothing here is.
347
+
348
+ Before the ADR-0021 cutover a report carried its own query: an `objectName`,
349
+ `columns` as column *definition objects* with `aggregate`, and
350
+ `groupingsDown` / `groupingsAcross` with `sortOrder` and `dateGranularity`.
351
+ That form is **rejected by the current schema** — `objectName` and
352
+ `groupingsDown` come back as unrecognized keys, and object-shaped `columns`
353
+ fails as the wrong type (`columns` is now a list of dimension names).
354
+
355
+ Stored documents in that shape still render, through a deliberately lossy
356
+ bridge: `ReportRenderer` converts them to a presentation report and hands that
357
+ to `ReportViewer`. The converter reads the *current* keys (`values`, `rows`), so
358
+ a pre-9.0 document arrives with none of them and what comes out is the report's
359
+ title over an **empty column set**. The document renders; its columns,
360
+ groupings, aggregates and sort do not, and nothing reports an error. That is why
361
+ the bridge is a waiting room rather than a supported way to write a report.
362
+
363
+ Migrating one is a re-expression against the dataset:
364
+
365
+ | Pre-9.0 key | Where it goes now |
366
+ | ---------------------------------- | ----------------------------------------------------------------------- |
367
+ | `objectName` | Gone from the report — the `dataset` owns the object. |
368
+ | `columns: [{ field, aggregate }]` | `values: string[]`; the aggregate becomes a **measure** in the dataset. |
369
+ | `columns: [{ field }]` | `rows: string[]`, as a dimension. |
370
+ | `groupingsDown: [{ field }]` | `rows: string[]`. |
371
+ | `groupingsAcross: [{ field }]` | `columns: string[]`. |
372
+ | `sortOrder` on a grouping | `order: [{ by, direction }]` on the report. |
373
+ | `dateGranularity` on a grouping | A time dimension declared at that grain **in the dataset**. |
374
+ | `filter` | `runtimeFilter`. |
375
+
376
+ The part with no mechanical equivalent is the dataset itself: measures and time
377
+ dimensions the old report declared inline must exist in the semantic layer
378
+ before the migrated report can name them.
379
+
380
+ The query-form renderers that used to draw these documents inline
381
+ (`SpecReportGrid`, `MatrixRenderer`, `JoinedReportRenderer`) were removed at the
382
+ cutover and have no replacement export — the bridge above is what renders a
383
+ stored old-shape document now.
384
+
385
+ ## Legacy presentation layer
386
+
387
+ `ReportViewer`, `LegacyReportRenderer` and the export functions below remain
388
+ available for pre-spec presentation reports (`{ report, data, columns, chart }`)
389
+ and are not affected by the dataset-bound pipeline above.
390
+
391
+ The authoring components that used to sit alongside them — `ReportBuilder`,
392
+ `ScheduleConfig`, `ChartConfig`, the columns/groupings editors — and the
393
+ `registerDrillHandler`-style drill helpers were **removed** in the 9.0 cutover
394
+ (see `CHANGELOG.md`). They have no replacement export in this package: report
395
+ authoring lives in the console designer, and drill-down is the `onDrill`
396
+ callback above.
397
+
398
+ ### Export
399
+
400
+ Every format exporter takes the **report schema first and the rows second** —
401
+ `(report: ReportComponentSchema, data: any[], config?: ReportExportConfig)`. The
402
+ schema is where the exporter reads the columns (`report.fields`) and the download
403
+ name (`config.filename`, else `report.title`) from; there is **no filename
404
+ parameter**. All five return `void` synchronously — they trigger a browser
405
+ download — so there is nothing to `await`:
162
406
 
163
407
  ```tsx
164
408
  import {
@@ -169,51 +413,177 @@ import {
169
413
  exportAsPDF,
170
414
  exportAsExcel,
171
415
  } from '@object-ui/plugin-report';
416
+ import type { ReportComponentSchema } from '@object-ui/types';
417
+
418
+ const report: ReportComponentSchema = {
419
+ type: 'report',
420
+ title: 'Sales Report',
421
+ fields: [
422
+ { name: 'account', label: 'Account' },
423
+ { name: 'amount', label: 'Amount', type: 'currency', aggregation: 'sum' },
424
+ ],
425
+ };
426
+ const rows = [{ account: 'Acme', amount: 12000 }];
427
+
428
+ exportAsCSV(report, rows); // downloads "Sales Report.csv"
429
+ exportAsJSON(report, rows); // "Sales Report.json"
430
+ exportAsHTML(report, rows); // "Sales Report.html"
431
+ exportAsPDF(report, rows); // opens a print window; falls back to .html if blocked
432
+ exportAsExcel(report, rows, { format: 'excel', filename: 'sales-2026-q1.tsv' });
172
433
 
173
- await exportAsCSV(reportData, 'sales-report.csv');
174
- await exportAsPDF(reportData, 'sales-report.pdf');
175
- await exportAsExcel(reportData, 'sales-report.xlsx');
434
+ // `exportReport` is the router — the format comes FIRST, then the same triple.
435
+ exportReport('csv', report, rows);
176
436
  ```
177
437
 
438
+ `exportAsExcel` writes a BOM-prefixed **TSV** that Excel opens, so its default
439
+ name is `report.title` + `.tsv`, not `.xlsx`. `config.includeHeaders: false` drops the
440
+ header row; `orientation` / `pageSize` apply to the HTML and PDF paths only.
441
+
178
442
  ### Live Export
179
443
 
180
- Export with real-time data and Excel formulas:
444
+ `exportWithLiveData` fetches the rows itself, so `dataSource` and `resource` are
445
+ **required** in `LiveExportOptions` — an options object carrying only `format`
446
+ does not compile. It is the one `async` export function, resolving to a
447
+ `LiveExportResult`:
181
448
 
182
449
  ```tsx
183
- import { exportWithLiveData, exportExcelWithFormulas } from '@object-ui/plugin-report';
450
+ import { exportWithLiveData } from '@object-ui/plugin-report';
451
+ import type { DataSource, ReportComponentSchema } from '@object-ui/types';
452
+
453
+ // `myAdapter` is a host-provided DataSource (see @object-ui/data-*).
454
+ declare const myAdapter: DataSource;
455
+ declare const report: ReportComponentSchema;
184
456
 
185
- await exportWithLiveData(reportConfig, { format: 'pdf' });
186
- await exportExcelWithFormulas(reportConfig, {
187
- columns: [{ field: 'total', formula: 'SUM(B2:B100)' }],
457
+ const result = await exportWithLiveData(report, {
458
+ dataSource: myAdapter,
459
+ resource: 'orders',
460
+ format: 'pdf',
188
461
  });
462
+ // -> { success: true, recordCount: 42, format: 'pdf' }
189
463
  ```
190
464
 
191
- ### ScheduleConfig
465
+ The format falls back to `report.defaultExportFormat`, then `'pdf'`. Failures are
466
+ returned, not thrown: `{ success: false, recordCount: 0, format, error }`.
192
467
 
193
- Configure recurring report generation:
468
+ `exportExcelWithFormulas` is a separate, synchronous three-parameter function —
469
+ `(report, data, options)` — with the column list inside the third argument. A
470
+ column is `{ name, header, width?, numberFormat?, formula? }`: `name` is the key
471
+ read off each row (there is no `field` key), `header` is required, and a `formula`
472
+ is a template in which the `{ROW}` placeholder is replaced by that row's
473
+ spreadsheet line number:
194
474
 
195
475
  ```tsx
196
- import { ScheduleConfig, createScheduleTrigger } from '@object-ui/plugin-report';
476
+ import { exportExcelWithFormulas } from '@object-ui/plugin-report';
477
+ import type { ReportComponentSchema } from '@object-ui/types';
197
478
 
198
- <ScheduleConfig
199
- reportId="monthly-sales"
200
- onSave={(schedule) => saveSchedule(schedule)}
201
- />
479
+ declare const report: ReportComponentSchema;
480
+ declare const rows: Array<Record<string, unknown>>;
202
481
 
203
- const trigger = createScheduleTrigger((reportId) => generateReport(reportId));
482
+ exportExcelWithFormulas(report, rows, {
483
+ columns: [
484
+ { name: 'amount', header: 'Amount', numberFormat: '#,##0.00' },
485
+ { name: 'total', header: 'Total', formula: '=B{ROW}*C{ROW}' },
486
+ ],
487
+ includeAggregationRow: true,
488
+ locale: 'de-DE',
489
+ });
204
490
  ```
205
491
 
492
+ Omitting `columns` derives them from `report.fields`. `includeAggregationRow`
493
+ appends a totals row built from each field's `aggregation`.
494
+
495
+ `locale` is the BCP-47 tag the numeric cells are formatted in — above, an
496
+ `amount` of `1234.5` writes `1.234,50` rather than `1,234.50`. Pass the tag the
497
+ session renders in: `useDisplayLocale()` from `@object-ui/i18n` in React, or
498
+ whatever your non-React caller resolved. This function is not a component and a
499
+ display locale is a property of the session rather than of the authored report
500
+ schema, so its caller is the only place the tag can come from. Omitted, cells
501
+ format in `'en'` — the display channel's own last resort, which is neither
502
+ `'en-US'` nor the machine's locale (objectui#10020).
503
+
504
+ ### Scheduled export
505
+
506
+ A schedule is **data on the report schema**, not a component: it is
507
+ `ReportComponentSchema.schedule`, typed `ReportScheduleConfig`. Both types live
508
+ in `@object-ui/types` and are *not* re-exported here — importing them from this
509
+ package is a TS2305.
510
+
511
+ `createScheduleTrigger` turns that declared schedule into a callable a workflow
512
+ engine can invoke. It takes the report, the data source, the resource to query
513
+ and a completion callback:
514
+
515
+ ```ts
516
+ import { createScheduleTrigger } from '@object-ui/plugin-report';
517
+ import type { DataSource, ReportComponentSchema, ReportScheduleConfig } from '@object-ui/types';
518
+
519
+ // `dataSource` and `notify` are host-provided.
520
+ declare const dataSource: DataSource;
521
+ declare const notify: (recipients: string[], subject: string | undefined) => void;
522
+
523
+ const report: ReportComponentSchema = {
524
+ type: 'report',
525
+ title: 'Monthly Sales',
526
+ schedule: {
527
+ enabled: true,
528
+ frequency: 'monthly',
529
+ dayOfMonth: 1,
530
+ time: '07:00',
531
+ formats: ['pdf', 'excel'],
532
+ recipients: ['sales@example.com'],
533
+ },
534
+ };
535
+
536
+ const trigger = createScheduleTrigger(
537
+ report,
538
+ dataSource,
539
+ 'orders',
540
+ (exported: ReportComponentSchema, schedule: ReportScheduleConfig) => {
541
+ notify(schedule.recipients ?? [], schedule.subject ?? exported.title);
542
+ },
543
+ );
544
+
545
+ const results = await trigger(); // LiveExportResult[] — one entry per scheduled format
546
+ ```
547
+
548
+ `trigger()` reads `report.schedule` itself: a schedule that is missing or
549
+ `enabled: false` exports nothing and resolves to `[]`. Otherwise it exports once
550
+ per entry in `schedule.formats` (falling back to `report.defaultExportFormat`,
551
+ then `'pdf'`) and calls the completion callback with the report and the schedule
552
+ it ran.
553
+
206
554
  ### Schema-Driven Usage
207
555
 
208
- Components auto-register with `ComponentRegistry`:
556
+ Importing the package registers two component types with `ComponentRegistry`:
557
+
558
+ | `type` | Component | Notes |
559
+ | --------------- | ---------------- | ------------------------------------------------------------ |
560
+ | `report` | `ReportRenderer` | The dispatcher. A spec report goes under its `report` key. |
561
+ | `report-viewer` | `ReportViewer` | Presentation-layer viewer. |
562
+
563
+ To embed a report, wrap the `@objectstack/spec` report definition (`ReportSchema`)
564
+ in a `report` node's `report` member. When `report` is present, the node renders
565
+ that report and reads none of its own presentation keys.
209
566
 
210
567
  ```json
211
568
  {
212
- "type": "report-builder",
213
- "report": { "sections": [] }
569
+ "type": "report",
570
+ "report": {
571
+ "name": "opp_by_stage",
572
+ "label": "Opportunities by Stage",
573
+ "type": "summary",
574
+ "dataset": "opportunity_pipeline",
575
+ "rows": ["stage"],
576
+ "values": ["amount_sum"]
577
+ }
214
578
  }
215
579
  ```
216
580
 
581
+ There is no `report-builder` component type — the authoring component that name
582
+ addressed was removed in the 9.0 cutover, so a node declaring it resolves to
583
+ nothing. `spec-report` is retired too (objectui#11440): it was an alias of `report`
584
+ carrying the same wrapper, and a node declaring it now resolves to nothing. Write
585
+ `"type": "report"` instead; the `report` member is unchanged.
586
+
217
587
  ### Type-aware cell rendering
218
588
 
219
589
  `ReportViewer` delegates cell rendering to the shared `getCellRenderer`
@@ -237,40 +607,11 @@ component appropriate for its type — instead of `String(value)`.
237
607
  | `richtext` / `html` / `markdown` | Sanitised inline content |
238
608
  | `json` | Collapsed code preview |
239
609
 
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
- ```
610
+ These are presentation-layer columns: each entry in `report.fields` carries its
611
+ own `name`, `label` and `type`, and that declared `type` is what selects the
612
+ renderer. This package resolves nothing from object metadata — there is no
613
+ `objectName` on a report to hydrate from, and a column with no `type` renders
614
+ as plain text.
274
615
 
275
616
  Legacy `renderAs: 'badge'` + `colorMap` is still honoured for plain
276
617
  string columns.