Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/25873-report-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-report': patch
---

feat(plugin-report): the report page offers CSV / XLSX export of the table it draws (`exportOptions`)

Clause-②: no
Metadata-protocol: none

`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:

- **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.
- **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.
- **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.
- **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).

`@object-ui/plugin-report` now depends on `@object-ui/permissions` and `exceljs`.
38 changes: 23 additions & 15 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2295,26 +2295,34 @@ describe('#26146 — ledger rows below the child level are graded and counted',
//
// `report` joined TYPE_COLLECTIONS with its ledger's first warned rows:
// `dateRange` and `exportOptions` shipped `planned` + `authorWarn` until the
// Console report page read them. `dateRange` is `live` since #25873's renderer
// slice (the report page draws its chip and windows every query), so its
// warning pin turned red at that flip ON PURPOSE and is re-subjected to the
// silence. `exportOptions` is still `planned` and is authored in the SAME call:
// one ledger load, one warning, so the silence is a reading and not a lint
// that stopped loading ledgers (which also answers []).
describe('report period and export (#26548 — dateRange live, exportOptions planned until the renderer reads it)', () => {
// Console report page read them. Both are `live` since #25873's renderer
// slices (the report page draws its period chip and its Export control), so
// each warning pin turned red at its flip ON PURPOSE and both are re-subjected
// to the silence. Both keys are authored beside a property that still warns
// (`object.externalSharingModel`, `dead`) in the SAME call: one ledger load,
// one warning, so the silence is a reading and not a lint that stopped loading
// ledgers (which also answers []).
describe('report period and export (#26548 — both live, read by the report renderer)', () => {
const LIT = { objects: [{ name: 'widget', externalSharingModel: 'read' }] };
const report = (extra: Record<string, unknown> = {}) => ({
reports: [{ name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], ...extra }],
});

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

it('CONTROL — a report declaring neither key draws no warning', () => {
Expand Down
33 changes: 16 additions & 17 deletions packages/spec/liveness/report.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,29 +80,28 @@
}
},
"exportOptions": {
"status": "planned",
"verifiedAt": "2026-10-27",
"authorWarn": true,
"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.",
"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.",
"status": "live",
"verifiedAt": "2026-10-28",
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#ReportExportControl, ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer",
"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.",
"children": {
"formats": {
"status": "planned",
"verifiedAt": "2026-10-27",
"evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `formats`, csv / xlsx only)",
"note": "The formats the export control offers; absent = both, `[]` = off. Planned with its container: no renderer draws the control."
"status": "live",
"verifiedAt": "2026-10-28",
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#readReportExportFormats",
"note": "The formats the control offers, in the declared order; absent = csv and xlsx, `[]` draws no control."
},
"includeHeaders": {
"status": "planned",
"verifiedAt": "2026-10-27",
"evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `includeHeaders`, the list export member)",
"note": "Whether the download carries a header row. Planned with its container: no renderer draws the control."
"status": "live",
"verifiedAt": "2026-10-28",
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#buildReportCsv, ui/packages/plugin-report/src/ReportExport.tsx#buildReportXlsx",
"note": "Whether the download opens with the table's header row (dimension and measure labels as drawn); absent = true."
},
"fileNamePrefix": {
"status": "planned",
"verifiedAt": "2026-10-27",
"evidence": "packages/spec/src/ui/list-view-export-options.ts#ReportExportOptionsSchema (declares `fileNamePrefix`, the list export member)",
"note": "The download file name prefix, in place of the report label. Planned with its container: no renderer draws the control."
"status": "live",
"verifiedAt": "2026-10-28",
"evidence": "ui/packages/plugin-report/src/ReportExport.tsx#ReportExportControl",
"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)."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/report.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `report` | 24 | 0 | 0 | 0 | 3 | 27 |
| `report` | 27 | 0 | 0 | 0 | 0 | 27 |
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ui/packages/plugin-report/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
"@object-ui/core": "workspace:*",
"@object-ui/fields": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/permissions": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"exceljs": "^4.4.0",
"lucide-react": "^1.43.0"
},
"peerDependencies": {
Expand Down
95 changes: 88 additions & 7 deletions ui/packages/plugin-report/src/DatasetReportRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplay
import { useDataInvalidation, classifyLoadError } from '@object-ui/react';
import { mergeFilters } from './mergeFilters';
import { useDatasetDimensionMeta, type DatasetDimensionMeta } from './useDatasetDimensionLabels';
import {
ReportExportControl,
ReportTableSnapshotContext,
PublishReportTableSnapshot,
exportDimensionCell,
exportMeasureCell,
type ReportTableSnapshot,
} from './ReportExport';
import {
ReportDateRangeControl,
initialReportPeriod,
Expand Down Expand Up @@ -221,6 +229,12 @@ interface DatasetReportLike {
label?: unknown;
description?: unknown;
blocks?: DatasetReportLike[];
/**
* The report's export control (`ReportSchema.exportOptions`): the formats
* offered and how the file is written — see `ReportExport.tsx`. Refused on a
* `joined` container by the spec, and not read there.
*/
exportOptions?: unknown;
}

export interface DatasetReportRendererProps {
Expand Down Expand Up @@ -874,8 +888,28 @@ function DatasetReportTable({
? state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.length === 0)?.rows?.[0]
: undefined;
const totalText = tt('report.total', 'Total');
const fmt = (measure: string, value: unknown) =>
formatMeasure(value, measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale);
// The cells below, as the export writes them (`ReportExport.tsx`).
const snapshot: ReportTableSnapshot = {
object: state.object,
header: columns.map((c) => headerLabel(c)),
rows: [
...layout.map((i) =>
columns.map((c) =>
values.includes(c)
? exportMeasureCell(fmt(c, displayRows[i][c]), displayRows[i][c])
: exportDimensionCell(formatDimensionValue(displayRows[i][c], displayLocale)),
),
),
...(grandTotal
? [[...rows.map((_, k) => ({ text: k === 0 ? totalText : '' })), ...values.map((m) => exportMeasureCell(fmt(m, grandTotal[m]), grandTotal[m]))]]
: []),
],
};
return (
<div className="overflow-auto max-h-[70vh] rounded-md border">
<PublishReportTableSnapshot snapshot={snapshot} />
<table className="w-full text-xs">
<thead className="bg-muted/40">
<tr>
Expand Down Expand Up @@ -1709,9 +1743,43 @@ function DatasetMatrixTable({
const grandTotal = findTotals([])?.[0];
const showTotalCol = rowTotalById.size > 0;
const showTotalRow = colTotalById.size > 0;
const fmt = (measure: string, value: unknown) =>
formatMeasure(value, measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale);
const totalHeader = (measure: string) => (values.length === 1 ? totalText : `${totalText} · ${headerLabel(measure)}`);
// The cross-tab below, as the export writes it (`ReportExport.tsx`).
const snapshot: ReportTableSnapshot = {
object: state.object,
header: [
...rows.map((d) => headerLabel(d)),
...cellCols.map((cc) => cc.header),
...(showTotalCol ? values.map(totalHeader) : []),
],
rows: [
...pivot.rowHeaders.map((rh) => [
...rows.map((d) => exportDimensionCell(formatDimensionValue(rh.display[d], displayLocale))),
...cellCols.map((cc) => {
const value = pivot.cells.get(pivotCellKey(rh.id, cc.col.id))?.row[cc.measure];
return exportMeasureCell(fmt(cc.measure, value), value);
}),
...(showTotalCol
? values.map((m) => exportMeasureCell(fmt(m, rowTotalById.get(rh.id)?.[m]), rowTotalById.get(rh.id)?.[m]))
: []),
]),
...(showTotalRow
? [[
...rows.map((_, k) => ({ text: k === 0 ? totalText : '' })),
...cellCols.map((cc) =>
exportMeasureCell(fmt(cc.measure, colTotalById.get(cc.col.id)?.[cc.measure]), colTotalById.get(cc.col.id)?.[cc.measure]),
),
...(showTotalCol ? values.map((m) => exportMeasureCell(fmt(m, grandTotal?.[m]), grandTotal?.[m])) : []),
]]
: []),
],
};

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

// The report's period (`dateRange`, objectstack-ai/objectstack#25873). A
// `joined` container has none — the spec refuses it there — so it is not
Expand Down Expand Up @@ -1976,20 +2046,30 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
// the ruling on objectstack-ai/objectstack#20293 draws a matrix's chart
// "beside or below the cross-tab", and below is the one placement that needs
// no new layout option.
//
// The export control (`exportOptions`, objectstack-ai/objectstack#25873)
// writes the table the report draws, which the table hands up through the
// snapshot context; it draws nothing until the table has rows.
return (
<ReportTableSnapshotContext.Provider value={setTableSnapshot}>
<div
className={`${className ?? ''} flex flex-col gap-3`}
data-testid="dataset-report"
data-report-name={report.name}
data-report-presentation={presentation}
>
{dateRangeDecl && period ? (
<ReportDateRangeControl
decl={dateRangeDecl}
value={period}
onChange={(next) => setPicked({ key: dateRangeKey, period: next })}
/>
) : null}
{/* One header row: the period chip on the left, Export on the right;
hidden when neither draws. */}
<div className="flex flex-wrap items-center justify-between gap-2 empty:hidden" data-testid="dataset-report-header">
{dateRangeDecl && period ? (
<ReportDateRangeControl
decl={dateRangeDecl}
value={period}
onChange={(next) => setPicked({ key: dateRangeKey, period: next })}
/>
) : null}
<ReportExportControl exportOptions={report.exportOptions} label={report.label} name={report.name} snapshot={tableSnapshot} />
</div>
{periodError ? (
<FetchStates status="error" error={periodError} />
) : periodPending ? (
Expand All @@ -2001,5 +2081,6 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
</>
)}
</div>
</ReportTableSnapshotContext.Provider>
);
};
Loading
Loading