diff --git a/.changeset/25873-report-export.md b/.changeset/25873-report-export.md new file mode 100644 index 00000000000..ba6a4b1de5a --- /dev/null +++ b/.changeset/25873-report-export.md @@ -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`. diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 1b06c726b95..b4218f21118 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -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 = {}) => ({ 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', () => { diff --git a/packages/spec/liveness/report.json b/packages/spec/liveness/report.json index f7981748de6..4e555bfbcf0 100644 --- a/packages/spec/liveness/report.json +++ b/packages/spec/liveness/report.json @@ -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)." } } }, diff --git a/packages/spec/liveness/state-counts/report.md b/packages/spec/liveness/state-counts/report.md index c9d2f401cac..6e2f0909118 100644 --- a/packages/spec/liveness/state-counts/report.md +++ b/packages/spec/liveness/state-counts/report.md @@ -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 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df629514623..a5aca604c49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5696,12 +5696,18 @@ importers: '@object-ui/i18n': specifier: workspace:* version: link:../i18n + '@object-ui/permissions': + specifier: workspace:* + version: link:../permissions '@object-ui/react': specifier: workspace:* version: link:../react '@object-ui/types': specifier: workspace:* version: link:../types + exceljs: + specifier: ^4.4.0 + version: 4.4.0 lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.3.0) diff --git a/ui/packages/plugin-report/package.json b/ui/packages/plugin-report/package.json index 8e487affcfa..9faceb0e9f1 100644 --- a/ui/packages/plugin-report/package.json +++ b/ui/packages/plugin-report/package.json @@ -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": { diff --git a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx index fe27e545656..616a74625ec 100644 --- a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -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, @@ -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 { @@ -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 (
+ @@ -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 (
+
@@ -1818,6 +1886,8 @@ export const DatasetReportRenderer: React.FC = ({ 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(null); // The report's period (`dateRange`, objectstack-ai/objectstack#25873). A // `joined` container has none — the spec refuses it there — so it is not @@ -1976,20 +2046,30 @@ export const DatasetReportRenderer: React.FC = ({ // 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 ( +
- {dateRangeDecl && period ? ( - setPicked({ key: dateRangeKey, period: next })} - /> - ) : null} + {/* One header row: the period chip on the left, Export on the right; + hidden when neither draws. */} +
+ {dateRangeDecl && period ? ( + setPicked({ key: dateRangeKey, period: next })} + /> + ) : null} + +
{periodError ? ( ) : periodPending ? ( @@ -2001,5 +2081,6 @@ export const DatasetReportRenderer: React.FC = ({ )}
+
); }; diff --git a/ui/packages/plugin-report/src/ReportExport.tsx b/ui/packages/plugin-report/src/ReportExport.tsx new file mode 100644 index 00000000000..f5850816c4d --- /dev/null +++ b/ui/packages/plugin-report/src/ReportExport.tsx @@ -0,0 +1,262 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The report page's export control — `ReportSchema.exportOptions` + * (`{ formats?, includeHeaders?, fileNamePrefix? }`, ruling 6408567749 on + * objectstack-ai/objectstack#25873, letter B). + * + * What is exported is the report table AS DRAWN: the table (or the matrix + * cross-tab) hands the cells it renders, totals included, to the report through + * {@link ReportTableSnapshotContext}, and the download writes exactly those. + * Nothing re-queries and nothing re-aggregates — the server totals are the + * ones on screen (the ADR-0021 governance red line), and the active + * `runtimeFilter` and period are whatever the table's own query carried. + * + * Formats are `csv` / `xlsx`, on by default: absent means both, `formats: []` + * means no control. Who may export is not a report key: the control is offered + * only when the viewer's effective API operations on the dataset's BASE object + * (the query answer's `object`) include `export` — the predicate the list + * toolbar applies (`ObjectPermission.allowExport`). An absent operation set (no + * provider, an unrestricted object, an older server) keeps it available, as it + * does on the list. + * + * Both files are written in the browser. The list's XLSX goes through the + * server record stream, which carries records, not aggregated report rows, so + * a report cannot use it; ExcelJS is imported only when an XLSX is asked for, + * so the report bundle does not carry it at load. + */ + +import * as React from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { Button, Popover, PopoverContent, PopoverTrigger } from '@object-ui/components'; +import { buildExportFileName } from '@object-ui/core'; +import { pickLocalized, useObjectTranslation } from '@object-ui/i18n'; +import { usePermissions } from '@object-ui/permissions'; + +/** One drawn cell: its text as shown, and the number behind a measure cell. */ +export interface ReportExportCell { + text: string; + /** Present on a measure cell whose value is a finite number (written as a number to XLSX). */ + value?: number; + /** A dimension value read from the data, guarded against spreadsheet formula injection. */ + fromData?: boolean; +} + +/** The report table as drawn: one header row, then body rows (totals last). */ +export interface ReportTableSnapshot { + /** The dataset's base object, from the query answer — what the export permission is read on. */ + object?: string; + header: string[]; + rows: ReportExportCell[][]; +} + +/** + * Where a drawn table reports its cells. `null` outside a report that offers + * export (a joined block, an embedded drill-down report), so publishing is a + * no-op there. + */ +export const ReportTableSnapshotContext = React.createContext<((snapshot: ReportTableSnapshot | null) => void) | null>( + null, +); + +/** + * Rendered inside a drawn table: hands its snapshot up, and withdraws it when + * the table stops drawing rows (loading, error, unmount). Keyed on the + * snapshot's CONTENT, never on the object's identity (ui AGENTS #10), so a + * re-render with the same cells publishes nothing new. + */ +export function PublishReportTableSnapshot({ snapshot }: { snapshot: ReportTableSnapshot }) { + const publish = React.useContext(ReportTableSnapshotContext); + const key = JSON.stringify(snapshot); + React.useEffect(() => { + if (!publish) return undefined; + publish(JSON.parse(key) as ReportTableSnapshot); + return () => publish(null); + }, [publish, key]); + return null; +} + +export type ReportTableExportFormat = 'csv' | 'xlsx'; + +const REPORT_EXPORT_FORMATS: readonly ReportTableExportFormat[] = ['csv', 'xlsx']; + +/** The `exportOptions` shape the renderer reads (validated by `ReportSchema`). */ +export interface ReportExportOptionsLike { + formats?: unknown; + includeHeaders?: unknown; + fileNamePrefix?: unknown; +} + +/** The formats the control offers: absent → both; `[]` → none; order as declared. */ +export function readReportExportFormats(options: unknown): ReportTableExportFormat[] { + const formats = (options as ReportExportOptionsLike | undefined)?.formats; + if (!Array.isArray(formats)) return [...REPORT_EXPORT_FORMATS]; + const out: ReportTableExportFormat[] = []; + for (const f of formats) { + if ((REPORT_EXPORT_FORMATS as readonly unknown[]).includes(f) && !out.includes(f as ReportTableExportFormat)) { + out.push(f as ReportTableExportFormat); + } + } + return out; +} + +/** + * A cell read from the data that opens with a formula character is prefixed + * with a tab, the guard `ReportExportEngine` applies. A measure cell is text + * this renderer formatted from a number, so a leading minus stays a minus. + */ +function guardFormula(cell: ReportExportCell): string { + const t = cell.text; + return cell.fromData && /^[=+\-@|]/.test(t) ? `\t${t}` : t; +} + +function csvField(text: string): string { + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** The CSV text of a snapshot (CRLF rows, RFC 4180 quoting). */ +export function buildReportCsv(snapshot: ReportTableSnapshot, includeHeaders: boolean): string { + const lines: string[] = []; + // A matrix's across-axis headers ARE dimension values from the data, so every + // header cell takes the guard (an authored label never opens with = + - @ |). + if (includeHeaders) lines.push(snapshot.header.map((h) => csvField(guardFormula({ text: h, fromData: true }))).join(',')); + for (const row of snapshot.rows) lines.push(row.map((c) => csvField(guardFormula(c))).join(',')); + return lines.join('\r\n'); +} + +/** The XLSX bytes of a snapshot: one sheet, measure cells as numbers. */ +export async function buildReportXlsx( + snapshot: ReportTableSnapshot, + includeHeaders: boolean, + sheetName: string, +): Promise { + const mod = await import('exceljs'); + const ExcelJS = (mod as unknown as { default?: typeof mod }).default ?? mod; + const wb = new ExcelJS.Workbook(); + // Excel refuses a sheet name over 31 characters or carrying any of : \ / ? * [ ] + const ws = wb.addWorksheet(sheetName.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31).trim() || 'Report'); + if (includeHeaders) { + const header = ws.addRow(snapshot.header); + header.font = { bold: true }; + } + for (const row of snapshot.rows) { + ws.addRow(row.map((c) => (c.value !== undefined ? c.value : guardFormula(c)))); + } + return (await wb.xlsx.writeBuffer()) as ArrayBuffer; +} + +function downloadBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + +export interface ReportExportControlProps { + /** `ReportSchema.exportOptions`, as stored. */ + exportOptions?: unknown; + /** The report's label (localizable) and name, for the file name. */ + label?: unknown; + name?: string; + /** The drawn table; no control until there is one to export. */ + snapshot: ReportTableSnapshot | null; +} + +/** The report header's Export control. Draws nothing when there is nothing the viewer may export. */ +export function ReportExportControl({ exportOptions, label, name, snapshot }: ReportExportControlProps) { + const { t, language } = useObjectTranslation(); + const { getObjectApiOperations } = usePermissions(); + const [open, setOpen] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const formats = readReportExportFormats(exportOptions); + if (formats.length === 0 || !snapshot) return null; + // The list toolbar's predicate, on the dataset's base object. + const ops = snapshot.object ? getObjectApiOperations(snapshot.object) : undefined; + if (ops && !ops.includes('export')) return null; + + const options = (exportOptions ?? {}) as ReportExportOptionsLike; + const includeHeaders = options.includeHeaders !== false; + const reportLabel = pickLocalized(label, language) || name || ''; + const prefix = typeof options.fileNamePrefix === 'string' ? options.fileNamePrefix : undefined; + + const run = async (format: ReportTableExportFormat) => { + setBusy(true); + setError(null); + try { + const fileName = buildExportFileName(format, { prefix, label: reportLabel, objectName: name }); + if (format === 'csv') { + // The BOM makes Excel open a UTF-8 CSV as UTF-8 (non-Latin labels). + downloadBlob(new Blob(['\uFEFF', buildReportCsv(snapshot, includeHeaders)], { type: 'text/csv;charset=utf-8' }), fileName); + } else { + const bytes = await buildReportXlsx(snapshot, includeHeaders, reportLabel || 'Report'); + downloadBlob(new Blob([bytes], { type: XLSX_MIME }), fileName); + } + setOpen(false); + } catch (e) { + setError(String((e as Error)?.message ?? e)); + } finally { + setBusy(false); + } + }; + + const exportText = t('list.export', { defaultValue: 'Export' }); + return ( +
+ + + + + +
+ {formats.map((format) => ( + + ))} + {error ? ( +
+ {error} +
+ ) : null} +
+
+
+
+ ); +} + +/** A measure cell: its drawn text, and the number when the value is a finite one. */ +export function exportMeasureCell(text: string, raw: unknown): ReportExportCell { + return typeof raw === 'number' && Number.isFinite(raw) ? { text, value: raw } : { text }; +} + +/** A dimension cell: a value read from the data. */ +export function exportDimensionCell(text: string): ReportExportCell { + return { text, fromData: true }; +} diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx new file mode 100644 index 00000000000..24e8dc4e126 --- /dev/null +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx @@ -0,0 +1,324 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The report page's export control reads `ReportSchema.exportOptions` + * (objectstack-ai/objectstack#25873, ruling 6408567749 letter B). + * + * - the download is the table AS DRAWN: its header row, its rows and the + * server total, compared cell by cell against the rendered table; + * - `formats` picks what is offered (absent = csv + xlsx, `[]` = no control); + * - the control is gated on `export` in the viewer's effective API operations + * on the dataset's base object, the list toolbar's predicate; an absent + * operation set (no provider) keeps it; + * - `includeHeaders: false` drops the header row, `fileNamePrefix` names the file; + * - the XLSX carries measure cells as numbers; + * - a matrix exports its cross-tab, totals column and row included. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react'; +// Imported at module scope so the control's lazy `import('exceljs')` resolves +// at once and the parse-back below reads the same module. +import ExcelJS from 'exceljs'; +import { I18nProvider } from '@object-ui/i18n'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { DatasetReportRenderer } from '../DatasetReportRenderer'; + +const OBJECT = 'crm_opportunity'; + +// A result that names its `object` makes the dimension-label hook read +// `/api/v1/meta/object/NAME` through the global `fetch` (no host `apiFetch` +// here). Answered by ONE module-scope double that stays up for the whole file +// (the network-escape guard's prescription), declaring no option fields, so +// the rows are drawn exactly as the server sent them. Any other URL is refused. +const META_OBJECT_ROUTE = /^\/api\/v1\/meta\/object\/(.+)$/; +const strayUrls: string[] = []; +vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input); + const m = META_OBJECT_ROUTE.exec(url); + if (!m) { + strayUrls.push(url); + return { ok: false, status: 404, json: async () => ({}) }; + } + return { ok: true, status: 200, json: async () => ({ item: { name: decodeURIComponent(m[1]), fields: {} } }) }; + }), +); + +function makeSource(result: Record) { + return { queryDataset: vi.fn(async () => result) }; +} + +const SUMMARY_RESULT = { + rows: [ + { stage: 'Prospecting', amount: 1200, n: 3 }, + { stage: 'Closed, Won', amount: 5000.5, n: 2 }, + { stage: '=HYPERLINK("x")', amount: -40, n: 1 }, + ], + fields: [ + { name: 'stage', type: 'string', label: 'Stage' }, + { name: 'amount', type: 'number', label: 'Amount' }, + { name: 'n', type: 'number', label: 'Deals' }, + ], + object: OBJECT, + totals: [{ dimensions: [], rows: [{ amount: 6160.5, n: 6 }] }], +}; + +const SUMMARY = { + name: 'pipeline_by_stage', + label: 'Pipeline by Stage', + type: 'summary', + dataset: 'opportunity_metrics', + rows: ['stage'], + values: ['amount', 'n'], +}; + +type Captured = { blob: Blob; fileName: string }; +let downloads: Captured[] = []; + +beforeEach(() => { + downloads = []; + let lastBlob: Blob | null = null; + (URL as unknown as { createObjectURL: (b: Blob) => string }).createObjectURL = vi.fn((b: Blob) => { + lastBlob = b; + return 'blob:report-export'; + }); + (URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + downloads.push({ blob: lastBlob!, fileName: this.download }); + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + expect(strayUrls).toEqual([]); +}); + +function permissions(apiOperations?: string[]): MePermissionsResponse { + return { + authenticated: true, + userId: 'usr_1', + tenantId: null, + roles: [], + permissionSets: [], + objects: { [OBJECT]: { allowRead: true, ...(apiOperations ? { apiOperations } : {}) } }, + fields: {}, + }; +} + +function renderReport(report: Record, opts: { result?: Record; perms?: MePermissionsResponse } = {}) { + const tree = ; + return render( + {opts.perms ? {tree} : tree}, + ); +} + +async function openMenu() { + fireEvent.click(await screen.findByRole('button', { name: 'Export' })); +} + +/** The rendered table, one array of cell texts per row (header first). */ +function drawnTable(): string[][] { + const table = screen.getByRole('table'); + return Array.from(table.querySelectorAll('tr')).map((tr) => + Array.from(tr.querySelectorAll('th,td')).flatMap((c) => { + const span = Number((c as HTMLTableCellElement).colSpan || 1); + return [c.textContent ?? '', ...Array.from({ length: span - 1 }, () => '')]; + }), + ); +} + +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; } + else if (ch === '"') quoted = false; + else field += ch; + } else if (ch === '"') quoted = true; + else if (ch === ',') { row.push(field); field = ''; } + else if (ch === '\r' && text[i + 1] === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; } + else field += ch; + } + row.push(field); + rows.push(row); + return rows; +} + +async function blobText(blob: Blob): Promise { + const text = await blob.text(); + return text.replace(/^\uFEFF/, ''); +} + +describe('report export control (exportOptions, #25873)', () => { + it('CSV of a summary report is the header row, the drawn rows and the server total', async () => { + renderReport(SUMMARY); + await screen.findByTestId('dataset-report-total-row'); + await openMenu(); + fireEvent.click(await screen.findByTestId('report-export-csv')); + await waitFor(() => expect(downloads).toHaveLength(1)); + + const csv = parseCsv(await blobText(downloads[0].blob)); + const drawn = drawnTable(); + expect(drawn[0]).toEqual(['Stage', 'Amount', 'Deals']); + expect(csv).toHaveLength(drawn.length); + // Cell for cell, except the data cell that opens with a formula character, + // which carries the tab guard. + csv.forEach((row, r) => + row.forEach((cell, c) => expect(cell).toBe(drawn[r][c].startsWith('=') ? `\t${drawn[r][c]}` : drawn[r][c])), + ); + expect(csv[csv.length - 1][0]).toBe('Total'); + // A drawn negative measure keeps its minus: only data cells are guarded. + expect(csv[3][1].startsWith('\t')).toBe(false); + expect(downloads[0].fileName).toMatch(/^Pipeline by Stage-\d{8}-\d{6}\.csv$/); + expect(downloads[0].blob.type).toContain('text/csv'); + }); + + it('absent formats offers both CSV and XLSX', async () => { + renderReport(SUMMARY); + await openMenu(); + expect(await screen.findByTestId('report-export-csv')).toBeInTheDocument(); + expect(screen.getByTestId('report-export-xlsx')).toBeInTheDocument(); + }); + + it("formats: ['csv'] offers only CSV", async () => { + renderReport({ ...SUMMARY, exportOptions: { formats: ['csv'] } }); + await openMenu(); + expect(await screen.findByTestId('report-export-csv')).toBeInTheDocument(); + expect(screen.queryByTestId('report-export-xlsx')).not.toBeInTheDocument(); + }); + + it('formats: [] draws no control', async () => { + renderReport({ ...SUMMARY, exportOptions: { formats: [] } }); + await screen.findByTestId('dataset-report-total-row'); + expect(screen.queryByTestId('report-export')).not.toBeInTheDocument(); + }); + + it("a viewer whose apiOperations on the base object exclude 'export' sees no control", async () => { + renderReport(SUMMARY, { perms: permissions(['find', 'get']) }); + await screen.findByTestId('dataset-report-total-row'); + expect(screen.queryByTestId('report-export')).not.toBeInTheDocument(); + }); + + it("CONTROL: the same provider with 'export' in apiOperations shows it", async () => { + renderReport(SUMMARY, { perms: permissions(['find', 'get', 'export']) }); + expect(await screen.findByTestId('report-export')).toBeInTheDocument(); + }); + + it('CONTROL: with no permissions provider the control is shown', async () => { + renderReport(SUMMARY); + expect(await screen.findByTestId('report-export')).toBeInTheDocument(); + }); + + it('includeHeaders: false drops the header row; fileNamePrefix names the file', async () => { + renderReport({ ...SUMMARY, exportOptions: { includeHeaders: false, fileNamePrefix: 'pipeline' } }); + await openMenu(); + fireEvent.click(await screen.findByTestId('report-export-csv')); + await waitFor(() => expect(downloads).toHaveLength(1)); + const csv = parseCsv(await blobText(downloads[0].blob)); + expect(csv[0][0]).toBe('Prospecting'); + expect(csv).toHaveLength(4); + expect(downloads[0].fileName).toMatch(/^pipeline-\d{8}-\d{6}\.csv$/); + }); + + it('XLSX carries the header, the drawn dimension text and the measures as numbers', async () => { + renderReport(SUMMARY); + await openMenu(); + fireEvent.click(await screen.findByTestId('report-export-xlsx')); + await waitFor(() => expect(downloads).toHaveLength(1)); + expect(downloads[0].fileName).toMatch(/\.xlsx$/); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(await downloads[0].blob.arrayBuffer()); + const ws = wb.worksheets[0]; + expect(ws.name).toBe('Pipeline by Stage'); + expect(ws.getRow(1).values).toEqual([undefined, 'Stage', 'Amount', 'Deals']); + expect(ws.getRow(3).values).toEqual([undefined, 'Closed, Won', 5000.5, 2]); + expect(ws.getRow(5).values).toEqual([undefined, 'Total', 6160.5, 6]); + }); + + it('a matrix exports its cross-tab with the totals column and row', async () => { + const result = { + rows: [ + { stage: 'A', quarter: 'Q1', amount: 1 }, + { stage: 'A', quarter: 'Q2', amount: 2 }, + { stage: 'B', quarter: 'Q1', amount: 3 }, + ], + fields: [ + { name: 'stage', type: 'string', label: 'Stage' }, + { name: 'quarter', type: 'string', label: 'Quarter' }, + { name: 'amount', type: 'number', label: 'Amount' }, + ], + object: OBJECT, + totals: [ + { dimensions: ['stage'], rows: [{ stage: 'A', amount: 3 }, { stage: 'B', amount: 3 }] }, + { dimensions: ['quarter'], rows: [{ quarter: 'Q1', amount: 4 }, { quarter: 'Q2', amount: 2 }] }, + { dimensions: [], rows: [{ amount: 6 }] }, + ], + }; + renderReport( + { name: 'by_stage_quarter', type: 'matrix', dataset: 'opportunity_metrics', rows: ['stage'], columns: ['quarter'], values: ['amount'] }, + { result }, + ); + const matrix = await screen.findByTestId('dataset-matrix'); + await within(matrix).findByTestId('matrix-total-row'); + await openMenu(); + fireEvent.click(await screen.findByTestId('report-export-csv')); + await waitFor(() => expect(downloads).toHaveLength(1)); + const csv = parseCsv(await blobText(downloads[0].blob)); + expect(csv).toEqual(drawnTable()); + expect(csv[0][csv[0].length - 1]).toBe('Total'); + expect(downloads[0].fileName).toMatch(/^by_stage_quarter-/); + }); + + it('a matrix across-axis value opening with a formula character is guarded in the CSV header', async () => { + const result = { + rows: [ + { stage: 'A', quarter: '=HYPERLINK("x")', amount: 1 }, + { stage: 'A', quarter: 'Q2', amount: 2 }, + ], + fields: [ + { name: 'stage', type: 'string', label: 'Stage' }, + { name: 'quarter', type: 'string', label: 'Quarter' }, + { name: 'amount', type: 'number', label: 'Amount' }, + ], + object: OBJECT, + }; + renderReport( + { name: 'by_stage_quarter', type: 'matrix', dataset: 'opportunity_metrics', rows: ['stage'], columns: ['quarter'], values: ['amount'] }, + { result }, + ); + await screen.findByTestId('dataset-matrix'); + await openMenu(); + fireEvent.click(await screen.findByTestId('report-export-csv')); + await waitFor(() => expect(downloads).toHaveLength(1)); + const header = parseCsv(await blobText(downloads[0].blob))[0]; + expect(drawnTable()[0]).toContain('=HYPERLINK("x")'); + expect(header).toContain('\t=HYPERLINK("x")'); + expect(header).not.toContain('=HYPERLINK("x")'); + // Authored labels pass through unchanged. + expect(header[0]).toBe('Stage'); + }); + + it('a joined report draws no export control', async () => { + renderReport({ + name: 'joined_one', + type: 'joined', + blocks: [{ name: 'b1', type: 'summary', dataset: 'opportunity_metrics', rows: ['stage'], values: ['amount'] }], + }); + await screen.findByTestId('dataset-report-total-row'); + expect(screen.queryByTestId('report-export')).not.toBeInTheDocument(); + }); +});