Skip to content

Commit 767fd06

Browse files
bloveclaude
andauthored
feat(react): CSV export on the surface, with docs and browser proof (#368)
* feat(react): CSV export on the surface, with docs Slice 3. Three props mirroring clipboard — `csvOptions`, `onExport`, `saveFile` — and `exportCsv(options?)` on the grid handle. This grid has no toolbar, so an imperative method the consumer calls from their own button is the trigger. `exportCsv` takes columns from the DRAWN order (never the `columns` prop), passes the scope `resolveDataScope` computed rather than assuming "all", builds `rowIds` from the selection when asked, and announces — saying so when the file is partial. Two things came out of review of the wiring and are worth naming: - `onlySelected` and `rowIds` together used to merge `rowIds` last, so the caller's explicit set vanished with nothing said. That is the silent narrowing `serializeCsv` refuses for an unknown `columnIds`; it now throws rather than picking a winner. - A SYMBOLIC all-selection (header checkbox, Cmd+A) leaves `selectedRowIds` empty, so it lands on the empty-selection branch and exports everything. That is the right answer, but it arrived by accident rather than by a rule — now stated in-source so a later change to either side is a decision. Docs: `grid/export.mdx`, registered in `_nav.ts` and in the guard roster — the options table bound to `PretableCsvOptions`, the omissions table bound to `PretableCsvOmission` through the discriminant-union check. All four registration mutations fail. Its `serializeCsv` fences are transcribed into a compile-time fixture; the `<PretableSurface>` fence is registered as untranscribed with a written reason, because the surface's generic inference needs a row type that does NOT extend `Record<string, unknown>` while `serializeCsv` needs exactly that, and one shared preamble cannot be both. That API is covered by `csv-export-surface.test.tsx` instead. 1001 react tests, 369 website tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): prove CSV export downloads a real file in a real browser jsdom can assert that `saveFile` got the right `PretableCsvFile`, and `csv.test.ts` does so exhaustively. It cannot answer whether a browser accepts the synthetic `<a download>` click as a download at all, whether it honours the name we computed, or whether the bytes survive the Blob URL round trip. Each of those is somewhere a green unit suite still ships a broken button. The fixture leaves `saveFile` unset so `defaultSaveFile` itself is what runs. Four assertions, all mutation-proven by breaking the fixture and watching the matching test go red: - Filename arrives exactly as built. Confirms the compact `20260813T202142Z` stamp is the right call: Chromium rewrites `:` to `_` on every OS, so a raw `toISOString()` would have been mangled here. (`name: "wrong"` → red.) - Byte 0 is 0xEF. Without the BOM Excel reads UTF-8 in the OS legacy codepage and `Größe` becomes mojibake. (`bom: false` → byte 0 is 83 → red.) - `=1+1` reaches disk apostrophe-prefixed while `-450` reaches it intact — both halves of the value-vouched rule in one file. (`escapeFormulas: false` → red.) - `onlySelected` exports the two checked rows, and an empty selection exports everything rather than a zero-row file. (Dropping `onlySelected` → red.) 8 passed across chromium and webkit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(react): close four export defects review found, and a false guard excuse Adversarial review of #368. Every item below was proven by a test that fails first; the four behavioural ones are in `csv-export-surface.test.tsx`. **The conflict refusal was watching one of two doors.** `onlySelected` + `rowIds` threw only for a call-site `rowIds`; a `rowIds` on the surface's `csvOptions` was overwritten silently — the exact silent narrowing the throw exists to refuse. It now reads the merged value. This also fixes the second half: with a surface `rowIds` set, an empty selection exported that other subset rather than everything, so the button quietly downloaded someone else's rows. **A synchronous throw from `saveFile` bypassed the failure path.** `Promise.resolve(saveFile(file))` evaluates the call before wrapping it, so a sync throw escaped `exportCsv` with nothing warned, nothing announced, and the rest of the click handler dead. `defaultSaveFile` is entirely synchronous DOM work, so this is the shape a real failure takes. Caught around the call rather than moved into a `.then` — the first attempt did exactly that and the suite caught it: deferring delivery a microtask is wrong for `<a download>` (which is chosen BECAUSE it survives an await, a property worth spending deliberately not accidentally) and dangerous for the identical clipboard path, where `writeText` IS activation-gated. **`.then(ok).catch(err)` reported a delivered file as failed.** A consumer's `exportAnnouncement` that throws was caught by the export's own failure branch: file on disk, screen-reader user told otherwise. Split into `.then(ok, err)` — which leaves the success branch to handle its own throw or it escapes as an unhandled rejection, so it warns under its own distinct message. The clipboard path had both bugs verbatim, being where the shape was copied from. Fixed there too. **A documented snippet that did not compile, behind an excuse that was false.** The `<PretableSurface>` fence was exempt from transcription on the recorded grounds that its row type could not extend `Record<string, unknown>`. Transcribing it showed the constraint was never the problem: the snippet was missing its explicit type argument and referenced an undeclared global. Both fixed, the fence transcribed, `UNTRANSCRIBED_FENCES` now empty. Two mutations kill it — a fixture that diverges from the page, and a fixture that stops naming the fence at all. That is the second time a guard escape hatch has been the thing that failed rather than the guard, and the first where the written reason was itself wrong. Also, smaller: `columnCount`'s TSDoc claimed to be the columns actually written and is the columns requested, which an `onExport` returning a hand-built file can contradict — stated, along with which fields ARE exact. `exportCsv` now documents both throws and says a save failure is not one of them. Two e2e comments asserted things the file disproves (the escaped field is not quoted; the buttons are `disabled` pre-hydration), and the escaping assertion is now the whole line, since `toContain` passed under both readings and so tested neither. `csvOptions`/`onExport`/`saveFile` are now documented in prose and listed beside their clipboard twins in the grid API reference. Not fixed here, filed instead: `rowIds` is typed `ReadonlySet<PretableRowId>` rather than the grid's `TRowId`, so a `Set<number>` on a string-id grid is accepted and yields an empty selection. Making `PretableCsvOptions` generic is a wider change than this PR should carry. 1023 react tests, 369 website tests, 8 e2e across chromium and webkit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changeset): record the clipboard half of the delivery fix Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2c30944 commit 767fd06

13 files changed

Lines changed: 1548 additions & 15 deletions

File tree

‎.changeset/csv-export-surface.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@pretable/react": minor
3+
---
4+
5+
Wire CSV export into `<PretableSurface>`.
6+
7+
Three props mirroring the clipboard trio — `csvOptions`, `onExport` (return
8+
`null` to cancel) and `saveFile` — plus `exportCsv(options?)` on the grid handle
9+
`onGridReady` hands you, since this grid has no toolbar and the trigger belongs
10+
to the consumer's own button.
11+
12+
`exportCsv` resolves columns from the DRAWN order, passes the scope
13+
`resolveDataScope` computed, and announces through the live region — including
14+
when the file is partial, which the announcement says out loud rather than
15+
leaving to `omissions`.
16+
17+
`onlySelected` and `rowIds` are refused together. They are two ways to name the
18+
same row set, and merging one over the other made the caller's explicit set
19+
vanish with nothing said.
20+
21+
Also fixes two defects on the **clipboard** path, which is where this code's
22+
shape was copied from and carried both faults verbatim: a `copyToClipboard`
23+
that threw synchronously escaped the failure branch entirely, and a
24+
`copyAnnouncement` that threw was reported as a failed copy. The clipboard
25+
write stays in the keystroke's own task — `writeText` is transient-activation
26+
gated, so deferring it even one microtask would put it outside the gesture that
27+
earned the permission.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* The code fences on `grid/export.mdx`, transcribed so `tsc --noEmit` compiles
3+
* them. A documented snippet that does not compile is a documented lie, and
4+
* MDX is not typechecked.
5+
*
6+
* Each `// docs-fence:` marker binds everything up to the next marker to the
7+
* fence under that heading. The preamble above the first marker is prepended to
8+
* every region, which is what lets several snippets share one import.
9+
*
10+
* The page's `<PretableSurface>` fence used to be exempt from this file, on
11+
* the recorded grounds that its row type could not extend
12+
* `Record<string, unknown>` while the `serializeCsv` fences require exactly
13+
* that. Transcribing it proved the reason false: the constraint was never what
14+
* broke it — the snippet was missing its explicit type argument and referenced
15+
* an undeclared global. Both are fixed on the page, and the exemption is gone.
16+
*/
17+
import {
18+
defaultSaveFile,
19+
PretableSurface,
20+
resolveDataScope,
21+
serializeCsv,
22+
type PretableColumn,
23+
type PretableRowModelSnapshot,
24+
type PretableSurfaceGrid,
25+
} from "@pretable/react";
26+
import { useRef } from "react";
27+
28+
interface Position extends Record<string, unknown> {
29+
id: string;
30+
symbol: string;
31+
}
32+
33+
declare const rowModelSnapshot: PretableRowModelSnapshot<
34+
Position,
35+
string,
36+
readonly { readonly id: string }[]
37+
>;
38+
declare const grid: { getColumns: () => readonly PretableColumn<Position>[] };
39+
declare const dataHonesty: Parameters<typeof resolveDataScope>[0];
40+
declare const processing: Parameters<typeof resolveDataScope>[1];
41+
42+
// docs-fence: grid/export.mdx#Getting a file
43+
const file = serializeCsv({
44+
rowModelSnapshot,
45+
columns: grid.getColumns(),
46+
scope: resolveDataScope(dataHonesty, processing),
47+
});
48+
49+
if (file) defaultSaveFile(file, { name: "positions" });
50+
51+
// docs-fence: grid/export.mdx#Formula escaping
52+
serializeCsv({
53+
rowModelSnapshot,
54+
columns: grid.getColumns(),
55+
scope: "all",
56+
options: { escapeFormulas: (value) => value.startsWith("=") },
57+
});
58+
59+
declare const columns: readonly PretableColumn<Position>[];
60+
61+
// docs-fence: grid/export.mdx#Getting a file (fence 2)
62+
export function PositionGrid({ positions }: { positions: Position[] }) {
63+
const grid = useRef<PretableSurfaceGrid<
64+
Position,
65+
string,
66+
readonly PretableColumn<Position>[]
67+
> | null>(null);
68+
69+
return (
70+
<>
71+
<button
72+
onClick={() => grid.current?.exportCsv({ onlySelected: true })}
73+
type="button"
74+
>
75+
Export selected
76+
</button>
77+
<PretableSurface<Position>
78+
ariaLabel="Positions"
79+
columns={columns}
80+
getRowId={(row) => row.id}
81+
onGridReady={(ready) => {
82+
grid.current = ready;
83+
}}
84+
rowSelectionColumn={{ enabled: true }}
85+
rows={positions}
86+
viewportHeight={520}
87+
/>
88+
</>
89+
);
90+
}

‎apps/website/app/docs/_nav.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const docsNav: DocsNavSection[] = [
5151
href: "/docs/grid/custom-rendering",
5252
},
5353
{ title: "Density helpers", href: "/docs/grid/density-helpers" },
54+
{ title: "CSV export", href: "/docs/grid/export" },
5455
{ title: "API reference", href: "/docs/grid/api-reference" },
5556
],
5657
},
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import {
4+
PretableSurface,
5+
type PretableColumn,
6+
type PretableSurfaceGrid,
7+
} from "@pretable/react";
8+
import { useMemo, useRef, useState } from "react";
9+
10+
/**
11+
* Test fixture for `apps/website/e2e/csv-export.spec.ts`.
12+
*
13+
* The serializer and the filename builder are covered exhaustively by unit
14+
* tests, and jsdom can assert that `saveFile` was called with the right bytes.
15+
* What jsdom cannot do is the part users actually experience: whether a real
16+
* browser accepts the synthetic `<a download>` click as a download at all,
17+
* whether it honours the filename we computed, and whether the bytes that
18+
* reach disk still carry the UTF-8 BOM and CRLF line endings once a `Blob`
19+
* URL has round-tripped through the download machinery.
20+
*
21+
* So this route deliberately leaves `saveFile` unset — `defaultSaveFile` is
22+
* the thing under test — and the spec reads the file Chromium wrote.
23+
*
24+
* Deliberately not part of the product surface; `app/fixtures/layout.tsx`
25+
* keeps it out of search engines.
26+
*/
27+
28+
interface PositionRow {
29+
id: string;
30+
symbol: string;
31+
desk: string;
32+
note: string;
33+
qty: number;
34+
}
35+
36+
const ROWS: PositionRow[] = [
37+
{ id: "p1", symbol: "AAPL", desk: "Equity", note: "core", qty: 1200 },
38+
// A leading `=` is the formula vector; escaping must reach disk, not just
39+
// the serializer's return value.
40+
{ id: "p2", symbol: "MSFT", desk: "Equity", note: "=1+1", qty: 800 },
41+
// A negative number must NOT be escaped — the Jira/MUI bug this design
42+
// exists to avoid. It is a real `number`, so it is exempt by JS type.
43+
{ id: "p3", symbol: "NVDA", desk: "Equity", note: "hedge", qty: -450 },
44+
// A comma and a quote force RFC 4180 quoting; a non-ASCII character proves
45+
// the BOM and the encoding survive the Blob round trip.
46+
{
47+
id: "p4",
48+
symbol: "SAP",
49+
desk: "Macro",
50+
note: 'Frankfurt, "Größe"',
51+
qty: 300,
52+
},
53+
];
54+
55+
const COLUMNS: PretableColumn<PositionRow>[] = [
56+
{ id: "symbol", header: "Symbol", widthPx: 120 },
57+
{ id: "desk", header: "Desk", widthPx: 140 },
58+
{ id: "note", header: "Note", widthPx: 220 },
59+
{ id: "qty", header: "Qty", type: "number", widthPx: 120 },
60+
];
61+
62+
export default function CsvExportFixturePage() {
63+
const rows = useMemo(() => ROWS, []);
64+
const columns = useMemo(() => COLUMNS, []);
65+
const gridRef = useRef<PretableSurfaceGrid<
66+
PositionRow,
67+
string,
68+
readonly PretableColumn<PositionRow>[]
69+
> | null>(null);
70+
const [ready, setReady] = useState(false);
71+
72+
return (
73+
<main style={{ padding: 24 }}>
74+
<h1 style={{ marginBottom: 12 }}>CSV export fixture</h1>
75+
<p>
76+
<button
77+
data-testid="export-all"
78+
disabled={!ready}
79+
onClick={() => gridRef.current?.exportCsv()}
80+
type="button"
81+
>
82+
Export
83+
</button>{" "}
84+
<button
85+
data-testid="export-selected"
86+
disabled={!ready}
87+
onClick={() => gridRef.current?.exportCsv({ onlySelected: true })}
88+
type="button"
89+
>
90+
Export selected
91+
</button>
92+
</p>
93+
<PretableSurface<PositionRow>
94+
ariaLabel="Positions"
95+
columns={columns}
96+
getRowId={(row) => row.id}
97+
onGridReady={(grid) => {
98+
gridRef.current = grid;
99+
setReady(true);
100+
}}
101+
rowSelectionColumn={{ enabled: true, headerCheckbox: true }}
102+
rows={rows}
103+
viewportHeight={300}
104+
/>
105+
</main>
106+
);
107+
}

‎apps/website/content/docs/grid/api-reference.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Both helpers return native `Intl.NumberFormatOptions` and are exported from `@pr
5959

6060
Rows with a conventional string or number `id` do not require `getRowId`. Otherwise pass a stable explicit accessor. Identity survives row-array replacements and is used by selection, focus, editing, expansion, and transactions.
6161

62-
Important shared props include `ariaLabel`, `viewportHeight`, `overscan`, `locale`, `state`, `onSelectionChange`, `onFocusChange`, `onPaste`, `onCopy`, `copyToClipboard`, `groupPanel`, `groupColumn`, `hideGroupedColumns`, and cell/header/row render hooks. Rows mode alone accepts the exact controlled `query`/`onQueryChange` pair, `onRowChange`, `initialExpansion`, and `aggregateFilteredRows`. Explicit-model mode alone accepts `beforeRowChange`.
62+
Important shared props include `ariaLabel`, `viewportHeight`, `overscan`, `locale`, `state`, `onSelectionChange`, `onFocusChange`, `onPaste`, `onCopy`, `copyToClipboard`, `csvOptions`, `onExport`, `saveFile`, `groupPanel`, `groupColumn`, `hideGroupedColumns`, and cell/header/row render hooks. Rows mode alone accepts the exact controlled `query`/`onQueryChange` pair, `onRowChange`, `initialExpansion`, and `aggregateFilteredRows`. Explicit-model mode alone accepts `beforeRowChange`.
6363

6464
The experimental `processing`, `resultMeta`, `dataState`, and `renderBodyState` props describe external filter/sort authority, total and dataset identity, and consumer-owned loading/error presentation. They do not move row or query ownership into the UI grid.
6565

0 commit comments

Comments
 (0)