@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/CHANGELOG.md +2084 -0
- package/README.md +470 -129
- package/dist/DatasetReportRenderer.d.ts +19 -3
- package/dist/LiveReportExporter.d.ts +19 -0
- package/dist/ReportExportEngine.d.ts +18 -3
- package/dist/ReportRenderer.d.ts +2 -2
- package/dist/displayLocale.d.ts +70 -0
- package/dist/formatValue.d.ts +19 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.js +501 -452
- package/dist/index.umd.cjs +22 -22
- package/dist/useDatasetDimensionLabels.d.ts +12 -1
- package/package.json +13 -16
package/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# @object-ui/plugin-report
|
|
2
2
|
|
|
3
|
-
Report components for Object UI —
|
|
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** —
|
|
9
|
-
- 📅 **
|
|
10
|
-
- 🪜 **Multi-level grouping + totals** — row
|
|
11
|
-
- 🎯 **Cell drill-down** —
|
|
12
|
-
- 🧱 **Joined reports** — vertically stacked sub-reports; each block
|
|
13
|
-
- 🎨 **Type-aware cells** — `select` → Badge, `lookup` → link, `boolean` → ✓/✗, `email`/`url`/`phone` → links, `image` → thumbnail
|
|
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": "
|
|
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 {
|
|
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
|
|
33
|
-
return
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
56
|
-
import
|
|
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
|
-
|
|
115
|
+
// `ds` is host-provided.
|
|
116
|
+
declare const ds: DataSource;
|
|
117
|
+
|
|
118
|
+
const report = defineReport({
|
|
60
119
|
name: 'opp_by_stage',
|
|
61
|
-
|
|
120
|
+
label: 'Opportunities by Stage',
|
|
62
121
|
type: 'summary',
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
|
75
|
-
|
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
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
|
-
|
|
167
|
+
label: 'Pipeline Coverage by Quarter',
|
|
87
168
|
type: 'matrix',
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
169
|
+
dataset: 'opportunity_pipeline',
|
|
170
|
+
rows: ['forecast_category'],
|
|
171
|
+
columns: ['close_quarter'],
|
|
172
|
+
values: ['amount_sum'],
|
|
173
|
+
});
|
|
92
174
|
```
|
|
93
175
|
|
|
94
|
-
`
|
|
95
|
-
|
|
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
|
|
181
|
+
### Joined
|
|
98
182
|
|
|
99
183
|
```ts
|
|
100
|
-
{
|
|
184
|
+
import { defineReport } from '@objectstack/spec/ui';
|
|
185
|
+
|
|
186
|
+
defineReport({
|
|
101
187
|
name: 'churn_signals',
|
|
102
|
-
|
|
188
|
+
label: 'Customer Churn Signals',
|
|
103
189
|
type: 'joined',
|
|
104
|
-
columns: [],
|
|
105
190
|
blocks: [
|
|
106
|
-
{
|
|
107
|
-
|
|
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:
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
available for
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
476
|
+
import { exportExcelWithFormulas } from '@object-ui/plugin-report';
|
|
477
|
+
import type { ReportComponentSchema } from '@object-ui/types';
|
|
197
478
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
onSave={(schedule) => saveSchedule(schedule)}
|
|
201
|
-
/>
|
|
479
|
+
declare const report: ReportComponentSchema;
|
|
480
|
+
declare const rows: Array<Record<string, unknown>>;
|
|
202
481
|
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
213
|
-
"report": {
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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.
|