Skip to content

Commit c07f034

Browse files
feat(plugin-report): the report page exports the drawn table as CSV / XLSX (exportOptions) (#27089)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8AW2QBu332oFgX78Gj9kfg9)_ Refs #25873 (item: the report page reads exportOptions) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:报表页右上角新增「导出」按钮,可把页面上画出来的那张表(含合计行/合计列)下载为 CSV 或 XLSX;`exportOptions` 从 planned 翻为 live。 - 为什么改:裁决 6408567749 选 B(时间范围 + 导出);spec 半边 #26548 已发布 `exportOptions`,但没有渲染器读它,报表无法带去开会。 - 风险与回滚:只影响非 joined 报表页的表头一行;权限沿用列表的 `export` 操作判定,无提供者时照列表默认放行;回滚 = revert 本 PR。 - 先看:`ui/packages/plugin-report/src/ReportExport.tsx`(权限判定与文件写出),`DatasetReportRenderer.tsx` 两处快照块,`packages/spec/liveness/report.json` 的 exportOptions 子树。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx`,预期 11 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **New module `ui/packages/plugin-report/src/ReportExport.tsx`.** `ReportExportControl` draws an Export popover offering the declared formats (absent = CSV and XLSX in that order, `formats: []` = no control). It reads the report's `exportOptions`, `label` and `name`. - **What is exported is the table as drawn.** `DatasetReportTable` (tabular / summary) and `DatasetMatrixTable` build a snapshot of exactly the cells they render (header labels, rows in layout order, server totals row and totals column) and hand it up through `ReportTableSnapshotContext`, keyed on content, not identity (ui AGENTS #10). The download re-queries nothing and re-aggregates nothing, so it inherits the table's own `runtimeFilter` and, once #27070 lands, its period. - **Who may export.** The control is drawn only when the viewer's effective API operations on the dataset base object (the query answer's `object`) include `export`, the list toolbar's predicate, read through `usePermissions().getObjectApiOperations` from `@object-ui/permissions`. An absent set (no provider, an unrestricted object) keeps it, as on the list. - **Files.** CSV: drawn text, UTF-8 with a byte-order mark, RFC 4180 quoting, CRLF rows; a dimension cell opening with a formula character gets the tab guard `ReportExportEngine` uses (a formatted measure keeps its minus); the tab guard applies to data cells and to every header cell (a matrix's across headers are data values). XLSX: one sheet named for the report, header bold, measure cells as numbers, dimension cells as drawn text; ExcelJS is `import()`ed only when an XLSX is asked for. File name = `buildExportFileName` from `@object-ui/core` (the list export's builder): `fileNamePrefix`, else the report label, else the name, plus a local timestamp. `includeHeaders: false` drops the header row. - **Not read on `joined`** (the spec refuses `exportOptions` there): the joined branch mounts no snapshot context, so its blocks publish nothing and no control is drawn. - **Dependencies.** `@object-ui/plugin-report` now depends on `@object-ui/permissions` (`workspace:*`) and `exceljs` (`^4.4.0`, the version `@object-ui/plugin-grid` already pins); `pnpm-lock.yaml` gains the importer lines only (+6). - **Liveness.** `packages/spec/liveness/report.json` `exportOptions` subtree flipped to `live` citing `ReportExport.tsx#ReportExportControl`, `authorWarn` / `authorHint` dropped in the same edit; `state-counts/report.md` regenerated (`gen:liveness-counts`). - **Lint liveness pin** (`packages/lint/src/lint-liveness-properties.test.ts`, the #26548 block, seat-accepted deviation): rewritten as a silence pin for `exportOptions` with an anti-vacuity read of the live row; `dateRange` still warns in the same call, so the ledger is proven loaded. - Changeset `.changeset/25873-report-export.md`: `@object-ui/plugin-report` patch. ## Why Ruling 6408567749 on #25873, letter B: "A, plus CSV / XLSX export of the report table through the existing list-export permission". The spec half shipped in #26548 (PR #26628); until this PR no renderer read `exportOptions`, so the key was inert. ## Tests New `ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx`, 12/12: - CSV of a summary report equals the rendered table cell for cell (header row, rows, `Total` row), formula guard on the data cell only, file name from the label. - absent `formats` offers CSV and XLSX; `formats: ['csv']` offers only CSV; `formats: []` draws no control. - a viewer whose `apiOperations` on the base object exclude `export` sees no control; CONTROL: same provider with `export` shows it; CONTROL: no provider shows it. - `includeHeaders: false` drops the header row; `fileNamePrefix` names the file. - XLSX parsed back with ExcelJS: sheet name, header, measures as numbers, total row. - a matrix CSV equals the rendered cross-tab, totals column and row included. - a matrix across-axis value opening with a formula character is guarded in the CSV header row. - a joined report draws no control. Ablation (at 54aa045, `node scripts/ablation-replace.mjs`): deleting the predicate line in `ReportExportControl` (anchor x1 to x0, blob 7eae13a to b22beff9) turns exactly 1 of 11 red, the `apiOperations` refusal test; restored blob 7eae13a equals the HEAD blob, `git diff HEAD` empty. No dist leg: the test imports the renderer by relative path. ## Acceptance notes - **Serial with #27070 (slice 1).** Both PRs insert the first child of the report root div (its period chip, this Export control), both rewrite the #26548 block of the lint liveness test, and both edit `liveness/report.json` (disjoint subtrees) and its `state-counts/report.md`. Whichever lands second merges `main`: keep both children (ideally one header row: chip left, Export right), make the lint block a silence pin for both keys with another warned control (the #26619 pattern uses `object.externalSharingModel`), and rerun `gen:liveness-counts`. - **File name and the period.** The spec TSDoc says the file is named for the report label and the period; on this base there is no period, so the name is label plus timestamp. Once #27070 lands, the period label can ride the builder's `viewLabel` slot; noted for whichever PR lands second. - **XLSX number formats.** Measure cells are written as raw numbers; currency / percent display formats are not carried into Excel number formats (the CSV carries the drawn text). - The export control and period are component state, not URL state; nothing addressable changed. ## Verification Head `858f0fe5`: - `pnpm --filter @object-ui/plugin-report run type-check && pnpm --dir ui exec vitest run packages/plugin-report/ && pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-liveness-properties.test.ts` under the verify lock: VERDICT command-exit 0; plugin-report 29 files, 269 passed / 7 skipped; lint 110/110. - `node scripts/pm/dispatch-gates.mjs --commands`: 83 families; `--ran` reconciliation: 82 run, all exit 0, 1 NOT MEASURED (`pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: not every package has `dist/` locally; CI runs it). First readings that were red and repaired on this branch: `pnpm --dir ui exec vitest run scripts/` (one-authority-per-exported-name: `ReportExportFormat` collided with `@object-ui/types`, renamed `ReportTableExportFormat`), `check:liveness` (state counts stale, regenerated), `check-plugin-teardown-shape --self-test` (exit 3, control sha outside the shallow clone, fetched). `node scripts/check-sdui-manifest.mjs` exit 0. - `pnpm --dir ui run check:new-line-citations` exit 0 (0 new); eslint on the three touched plugin-report files: 0 errors (react-refresh warnings only, same rule the renderer already carries); `node scripts/check-nul-bytes.mjs` exit 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; repo-wide `pnpm lint` is CI-owned.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYfDeaywgrS5N1PZfg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b322b5d commit c07f034

9 files changed

Lines changed: 739 additions & 40 deletions

File tree

‎.changeset/25873-report-export.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/plugin-report': patch
3+
---
4+
5+
feat(plugin-report): the report page offers CSV / XLSX export of the table it draws (`exportOptions`)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`ReportSchema.exportOptions` (`{ formats?, includeHeaders?, fileNamePrefix? }`) was accepted but read by nothing, so a report table could not be downloaded. The report page now reads it:
11+
12+
- **The control.** A non-joined report draws an Export control above its body once its table has rows, offering the declared formats in order: absent means CSV and XLSX, `formats: []` draws no control.
13+
- **What is exported.** The table as drawn: the grouped table or the matrix cross-tab, its header row (dimension and measure labels as shown), its rows in the order shown and the server totals it draws. The file carries whatever the table's own query carried (the report's `runtimeFilter` and, where declared, its period); nothing is re-queried or re-aggregated. `includeHeaders: false` drops the header row.
14+
- **Who may export.** Not a report key: the control is offered only when the viewer's effective API operations on the dataset's base object include `export` (`ObjectPermission.allowExport`, the predicate the list toolbar uses). An absent operation set (no permissions provider, an unrestricted object) keeps it, as on the list.
15+
- **The files.** Both are written in the browser. The CSV is the drawn text (UTF-8 with a byte-order mark, RFC 4180 quoting; a data cell opening with a formula character is prefixed with a tab). The XLSX writes measure cells as numbers; ExcelJS is imported only when an XLSX is asked for. The file is named `fileNamePrefix`, else the report label, plus a local timestamp (the list export's file name builder).
16+
17+
`@object-ui/plugin-report` now depends on `@object-ui/permissions` and `exceljs`.

‎packages/lint/src/lint-liveness-properties.test.ts‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2295,26 +2295,34 @@ describe('#26146 — ledger rows below the child level are graded and counted',
22952295
//
22962296
// `report` joined TYPE_COLLECTIONS with its ledger's first warned rows:
22972297
// `dateRange` and `exportOptions` shipped `planned` + `authorWarn` until the
2298-
// Console report page read them. `dateRange` is `live` since #25873's renderer
2299-
// slice (the report page draws its chip and windows every query), so its
2300-
// warning pin turned red at that flip ON PURPOSE and is re-subjected to the
2301-
// silence. `exportOptions` is still `planned` and is authored in the SAME call:
2302-
// one ledger load, one warning, so the silence is a reading and not a lint
2303-
// that stopped loading ledgers (which also answers []).
2304-
describe('report period and export (#26548 — dateRange live, exportOptions planned until the renderer reads it)', () => {
2298+
// Console report page read them. Both are `live` since #25873's renderer
2299+
// slices (the report page draws its period chip and its Export control), so
2300+
// each warning pin turned red at its flip ON PURPOSE and both are re-subjected
2301+
// to the silence. Both keys are authored beside a property that still warns
2302+
// (`object.externalSharingModel`, `dead`) in the SAME call: one ledger load,
2303+
// one warning, so the silence is a reading and not a lint that stopped loading
2304+
// ledgers (which also answers []).
2305+
describe('report period and export (#26548 — both live, read by the report renderer)', () => {
2306+
const LIT = { objects: [{ name: 'widget', externalSharingModel: 'read' }] };
23052307
const report = (extra: Record<string, unknown> = {}) => ({
23062308
reports: [{ name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], ...extra }],
23072309
});
23082310

2309-
it('warns on exportOptions only, at the report, with the row\'s author hint; dateRange is silent', () => {
2310-
const findings = lintLivenessProperties(report({ dateRange: { dimension: 'close_date' }, exportOptions: { formats: ['csv'] } }));
2311-
const planned = findings.filter((f) => f.rule === 'liveness-planned-property');
2312-
expect(planned.map((f) => f.message.match(/`(dateRange|exportOptions)`/)?.[1]).sort()).toEqual(['exportOptions']);
2313-
for (const f of planned) {
2314-
expect(f.where).toContain("report 'pipeline'");
2315-
expect(f.hint).toMatch(/^Keep it, but nothing reads it yet: the Console report page draws no /);
2311+
it('is silent on an authored `dateRange` and `exportOptions` — both rows are `live`, cite the renderer, and carry no author warning', () => {
2312+
const findings = lintLivenessProperties({ ...LIT, ...report({ dateRange: { dimension: 'close_date' }, exportOptions: { formats: ['csv'] } }) });
2313+
expect(ruleOf(findings, 'externalSharingModel')).toBe(LIVENESS_DEAD_PROPERTY);
2314+
expect(findings.filter((f) => /`(dateRange|exportOptions)`/.test(f.message))).toEqual([]);
2315+
const props = JSON.parse(readFileSync(join(shippedLedgerDir(), 'report.json'), 'utf8')).props;
2316+
// Anti-vacuity: each row is the shape this silence pin exists for.
2317+
for (const [key, evidence] of [
2318+
['dateRange', 'ui/packages/plugin-report/src/ReportDateRange.tsx#reportTimeWindow'],
2319+
['exportOptions', 'ui/packages/plugin-report/src/ReportExport.tsx#ReportExportControl'],
2320+
] as const) {
2321+
expect(props[key].status).toBe('live');
2322+
expect(props[key]).not.toHaveProperty('authorWarn');
2323+
expect(props[key]).not.toHaveProperty('authorHint');
2324+
expect(props[key].evidence).toContain(evidence);
23162325
}
2317-
expect(findings.filter((f) => /`dateRange`/.test(f.message))).toEqual([]);
23182326
});
23192327

23202328
it('CONTROL — a report declaring neither key draws no warning', () => {

‎packages/spec/liveness/report.json‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,29 +80,28 @@
8080
}
8181
},
8282
"exportOptions": {
83-
"status": "planned",
84-
"verifiedAt": "2026-10-27",
85-
"authorWarn": true,
86-
"authorHint": "Keep it, but nothing reads it yet: the Console report page draws no export control, so the report table cannot be downloaded until the report renderer reads this key.",
87-
"note": "PLANNED RECORD (#26548, the spec half of ruling 6408567749 on #25873, letter B). The report export control `{ formats?, includeHeaders?, fileNamePrefix? }`: three members of the list export block (ReportExportOptionsSchema in packages/spec/src/ui/list-view-export-options.ts), formats `csv` / `xlsx` only, on by default (absent = both, `formats: []` = off). Who may export is NOT a report key: the control is gated on the viewer having `export` in the effective apiOperations of the dataset base object (ObjectPermission.allowExport, the predicate the list toolbar uses). Refused on a `joined` container. PLANNED, not live: no renderer reads it. The report page (ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer) draws no export control, no source under ui/packages/plugin-report/src/ names `exportOptions` (0 hits outside tests), and the client-side CSV writer in ui/packages/plugin-report/src/ReportExportEngine.ts serves the legacy presentation schema, not ReportSchema. The renderer writes XLSX in the browser: the list XLSX path is the server record stream, which cannot carry aggregated report rows. Carrier: #25873. Flip to `live` on the commit that cites the renderer read point, and drop `authorWarn` / `authorHint` in that same edit.",
83+
"status": "live",
84+
"verifiedAt": "2026-10-28",
85+
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#ReportExportControl, ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer",
86+
"note": "#25873 (ruling 6408567749, letter B; spec half #26548). The report export control `{ formats?, includeHeaders?, fileNamePrefix? }`: three members of the list export block (ReportExportOptionsSchema in packages/spec/src/ui/list-view-export-options.ts), formats `csv` / `xlsx` only, on by default (absent = both, `formats: []` = off). Refused on a `joined` container, and not read there. LIVE: a non-joined report draws an Export control (ReportExport.tsx#ReportExportControl, read from DatasetReportRenderer.tsx#DatasetReportRenderer as `report.exportOptions`) once its table has rows. The download is the table AS DRAWN: the grouped table or the matrix cross-tab hands the cells it renders, server totals included, up through ReportExport.tsx#ReportTableSnapshotContext, so the file carries the active `runtimeFilter` and period of the table's own query and nothing is re-queried or re-aggregated. Who may export is NOT a report key: the control is offered only when the viewer's effective apiOperations on the dataset base object (the query answer's `object`) include `export` (ObjectPermission.allowExport, the predicate the list toolbar uses, read through `@object-ui/permissions` usePermissions); an absent operation set (no provider, an unrestricted object) keeps it, as on the list. Both files are written in the browser: the list XLSX path is the server record stream, which cannot carry aggregated report rows, so the XLSX is built with ExcelJS imported only when asked for. Pinned by ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx.",
8887
"children": {
8988
"formats": {
90-
"status": "planned",
91-
"verifiedAt": "2026-10-27",
92-
"evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `formats`, csv / xlsx only)",
93-
"note": "The formats the export control offers; absent = both, `[]` = off. Planned with its container: no renderer draws the control."
89+
"status": "live",
90+
"verifiedAt": "2026-10-28",
91+
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#readReportExportFormats",
92+
"note": "The formats the control offers, in the declared order; absent = csv and xlsx, `[]` draws no control."
9493
},
9594
"includeHeaders": {
96-
"status": "planned",
97-
"verifiedAt": "2026-10-27",
98-
"evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `includeHeaders`, the list export member)",
99-
"note": "Whether the download carries a header row. Planned with its container: no renderer draws the control."
95+
"status": "live",
96+
"verifiedAt": "2026-10-28",
97+
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#buildReportCsv, ui/packages/plugin-report/src/ReportExport.tsx#buildReportXlsx",
98+
"note": "Whether the download opens with the table's header row (dimension and measure labels as drawn); absent = true."
10099
},
101100
"fileNamePrefix": {
102-
"status": "planned",
103-
"verifiedAt": "2026-10-27",
104-
"evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `fileNamePrefix`, the list export member)",
105-
"note": "The download file name prefix, in place of the report label. Planned with its container: no renderer draws the control."
101+
"status": "live",
102+
"verifiedAt": "2026-10-28",
103+
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#ReportExportControl",
104+
"note": "Replaces the report label as the file name base; the name is built by `@object-ui/core` buildExportFileName (base-YYYYMMDD-HHMMSS.ext, the list export's builder)."
106105
}
107106
}
108107
},

‎packages/spec/liveness/state-counts/report.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `report` | 24 | 0 | 0 | 0 | 3 | 27 |
15+
| `report` | 27 | 0 | 0 | 0 | 0 | 27 |

‎pnpm-lock.yaml‎

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎ui/packages/plugin-report/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@
3535
"@object-ui/core": "workspace:*",
3636
"@object-ui/fields": "workspace:*",
3737
"@object-ui/i18n": "workspace:*",
38+
"@object-ui/permissions": "workspace:*",
3839
"@object-ui/react": "workspace:*",
3940
"@object-ui/types": "workspace:*",
41+
"exceljs": "^4.4.0",
4042
"lucide-react": "^1.43.0"
4143
},
4244
"peerDependencies": {

‎ui/packages/plugin-report/src/DatasetReportRenderer.tsx‎

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplay
118118
import { useDataInvalidation, classifyLoadError } from '@object-ui/react';
119119
import { mergeFilters } from './mergeFilters';
120120
import { useDatasetDimensionMeta, type DatasetDimensionMeta } from './useDatasetDimensionLabels';
121+
import {
122+
ReportExportControl,
123+
ReportTableSnapshotContext,
124+
PublishReportTableSnapshot,
125+
exportDimensionCell,
126+
exportMeasureCell,
127+
type ReportTableSnapshot,
128+
} from './ReportExport';
121129
import {
122130
ReportDateRangeControl,
123131
initialReportPeriod,
@@ -221,6 +229,12 @@ interface DatasetReportLike {
221229
label?: unknown;
222230
description?: unknown;
223231
blocks?: DatasetReportLike[];
232+
/**
233+
* The report's export control (`ReportSchema.exportOptions`): the formats
234+
* offered and how the file is written — see `ReportExport.tsx`. Refused on a
235+
* `joined` container by the spec, and not read there.
236+
*/
237+
exportOptions?: unknown;
224238
}
225239

226240
export interface DatasetReportRendererProps {
@@ -874,8 +888,28 @@ function DatasetReportTable({
874888
? state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.length === 0)?.rows?.[0]
875889
: undefined;
876890
const totalText = tt('report.total', 'Total');
891+
const fmt = (measure: string, value: unknown) =>
892+
formatMeasure(value, measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale);
893+
// The cells below, as the export writes them (`ReportExport.tsx`).
894+
const snapshot: ReportTableSnapshot = {
895+
object: state.object,
896+
header: columns.map((c) => headerLabel(c)),
897+
rows: [
898+
...layout.map((i) =>
899+
columns.map((c) =>
900+
values.includes(c)
901+
? exportMeasureCell(fmt(c, displayRows[i][c]), displayRows[i][c])
902+
: exportDimensionCell(formatDimensionValue(displayRows[i][c], displayLocale)),
903+
),
904+
),
905+
...(grandTotal
906+
? [[...rows.map((_, k) => ({ text: k === 0 ? totalText : '' })), ...values.map((m) => exportMeasureCell(fmt(m, grandTotal[m]), grandTotal[m]))]]
907+
: []),
908+
],
909+
};
877910
return (
878911
<div className="overflow-auto max-h-[70vh] rounded-md border">
912+
<PublishReportTableSnapshot snapshot={snapshot} />
879913
<table className="w-full text-xs">
880914
<thead className="bg-muted/40">
881915
<tr>
@@ -1709,9 +1743,43 @@ function DatasetMatrixTable({
17091743
const grandTotal = findTotals([])?.[0];
17101744
const showTotalCol = rowTotalById.size > 0;
17111745
const showTotalRow = colTotalById.size > 0;
1746+
const fmt = (measure: string, value: unknown) =>
1747+
formatMeasure(value, measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale);
1748+
const totalHeader = (measure: string) => (values.length === 1 ? totalText : `${totalText} · ${headerLabel(measure)}`);
1749+
// The cross-tab below, as the export writes it (`ReportExport.tsx`).
1750+
const snapshot: ReportTableSnapshot = {
1751+
object: state.object,
1752+
header: [
1753+
...rows.map((d) => headerLabel(d)),
1754+
...cellCols.map((cc) => cc.header),
1755+
...(showTotalCol ? values.map(totalHeader) : []),
1756+
],
1757+
rows: [
1758+
...pivot.rowHeaders.map((rh) => [
1759+
...rows.map((d) => exportDimensionCell(formatDimensionValue(rh.display[d], displayLocale))),
1760+
...cellCols.map((cc) => {
1761+
const value = pivot.cells.get(pivotCellKey(rh.id, cc.col.id))?.row[cc.measure];
1762+
return exportMeasureCell(fmt(cc.measure, value), value);
1763+
}),
1764+
...(showTotalCol
1765+
? values.map((m) => exportMeasureCell(fmt(m, rowTotalById.get(rh.id)?.[m]), rowTotalById.get(rh.id)?.[m]))
1766+
: []),
1767+
]),
1768+
...(showTotalRow
1769+
? [[
1770+
...rows.map((_, k) => ({ text: k === 0 ? totalText : '' })),
1771+
...cellCols.map((cc) =>
1772+
exportMeasureCell(fmt(cc.measure, colTotalById.get(cc.col.id)?.[cc.measure]), colTotalById.get(cc.col.id)?.[cc.measure]),
1773+
),
1774+
...(showTotalCol ? values.map((m) => exportMeasureCell(fmt(m, grandTotal?.[m]), grandTotal?.[m])) : []),
1775+
]]
1776+
: []),
1777+
],
1778+
};
17121779

17131780
return (
17141781
<div className="overflow-auto max-h-[70vh] rounded-md border" data-testid="dataset-matrix">
1782+
<PublishReportTableSnapshot snapshot={snapshot} />
17151783
<table className="w-full text-xs">
17161784
<thead className="bg-muted/40">
17171785
<tr>
@@ -1818,6 +1886,8 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
18181886
const drillSink = report.drilldown === false ? undefined : onDrill;
18191887
// The DECLARED type drives the branch (#2941) — never the data shape.
18201888
const presentation = resolveReportPresentation(report.type);
1889+
// The drawn table's cells, for the export control (not read on `joined`).
1890+
const [tableSnapshot, setTableSnapshot] = React.useState<ReportTableSnapshot | null>(null);
18211891

18221892
// The report's period (`dateRange`, objectstack-ai/objectstack#25873). A
18231893
// `joined` container has none — the spec refuses it there — so it is not
@@ -1976,20 +2046,30 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
19762046
// the ruling on objectstack-ai/objectstack#20293 draws a matrix's chart
19772047
// "beside or below the cross-tab", and below is the one placement that needs
19782048
// no new layout option.
2049+
//
2050+
// The export control (`exportOptions`, objectstack-ai/objectstack#25873)
2051+
// writes the table the report draws, which the table hands up through the
2052+
// snapshot context; it draws nothing until the table has rows.
19792053
return (
2054+
<ReportTableSnapshotContext.Provider value={setTableSnapshot}>
19802055
<div
19812056
className={`${className ?? ''} flex flex-col gap-3`}
19822057
data-testid="dataset-report"
19832058
data-report-name={report.name}
19842059
data-report-presentation={presentation}
19852060
>
1986-
{dateRangeDecl && period ? (
1987-
<ReportDateRangeControl
1988-
decl={dateRangeDecl}
1989-
value={period}
1990-
onChange={(next) => setPicked({ key: dateRangeKey, period: next })}
1991-
/>
1992-
) : null}
2061+
{/* One header row: the period chip on the left, Export on the right;
2062+
hidden when neither draws. */}
2063+
<div className="flex flex-wrap items-center justify-between gap-2 empty:hidden" data-testid="dataset-report-header">
2064+
{dateRangeDecl && period ? (
2065+
<ReportDateRangeControl
2066+
decl={dateRangeDecl}
2067+
value={period}
2068+
onChange={(next) => setPicked({ key: dateRangeKey, period: next })}
2069+
/>
2070+
) : null}
2071+
<ReportExportControl exportOptions={report.exportOptions} label={report.label} name={report.name} snapshot={tableSnapshot} />
2072+
</div>
19932073
{periodError ? (
19942074
<FetchStates status="error" error={periodError} />
19952075
) : periodPending ? (
@@ -2001,5 +2081,6 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
20012081
</>
20022082
)}
20032083
</div>
2084+
</ReportTableSnapshotContext.Provider>
20042085
);
20052086
};

0 commit comments

Comments
 (0)