You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c07f034
Browse filesBrowse the repository at this point in the historyBrowse 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>
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`.
Copy file name to clipboardExpand all lines: packages/spec/liveness/report.json
+16-17Lines changed: 16 additions & 17 deletions
Original file line number
Diff line number
Diff line change
@@ -80,29 +80,28 @@
80
80
}
81
81
},
82
82
"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.",
"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.",
"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)."
0 commit comments