Skip to content

Commit c68b790

Browse files
authored
docs: add configurable CSV preview and matching download (#596)
1 parent d5a949d commit c68b790

10 files changed

Lines changed: 337 additions & 3 deletions

File tree

‎apps/website/content/docs/grid/export.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ export, or narrow the predicate as above.
177177

178178
## Options
179179

180+
Choose a column subset, headers, and delimiter below, then click **Preview CSV**.
181+
The grid's `exportCsv` passes the actual `PretableCsvFile` to `saveFile`, which
182+
captures it for display. **Download CSV** passes that same file to
183+
`defaultSaveFile` with a timestamped `orders-preview` filename.
184+
185+
<Example id="export-preview" />
186+
187+
Changing any export setting clears the preview; generate it again before
188+
downloading. The preview captures the current grid output, including the Total
189+
column's USD `numberFormat`. Commas and quotation marks in the customer names
190+
show how the serializer quotes fields. This example uses `bom: false` so the
191+
preview text is exactly the downloaded text, without an invisible BOM.
192+
180193
| Option | Type | Required | Description |
181194
| ---------------------- | ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
182195
| `delimiter` | `string` | no | Field separator, default `","`. Excel follows the OS list separator, which is `;` across much of Europe. |
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import {
6+
defaultSaveFile,
7+
PretableButton,
8+
PretableSurface,
9+
} from "@pretable/react";
10+
import type { PretableCsvFile, PretableSurfaceGrid } from "@pretable/react";
11+
12+
import { columns } from "./columns";
13+
import { orders, type Order } from "./data";
14+
15+
type OrdersGrid = PretableSurfaceGrid<Order, string, typeof columns>;
16+
17+
const exportColumns = [
18+
{ id: "id", label: "Order" },
19+
{ id: "customer", label: "Customer" },
20+
{ id: "total", label: "Total" },
21+
] as const;
22+
const getRowId = (order: Order) => order.id;
23+
24+
export function ExportPreviewGrid() {
25+
const [grid, setGrid] = useState<OrdersGrid | null>(null);
26+
const [columnIds, setColumnIds] = useState<readonly string[]>(
27+
exportColumns.map((column) => column.id),
28+
);
29+
const [includeHeaders, setIncludeHeaders] = useState(true);
30+
const [delimiter, setDelimiter] = useState(",");
31+
const [preview, setPreview] = useState<PretableCsvFile | null>(null);
32+
33+
return (
34+
<div style={{ minWidth: 0 }}>
35+
<p style={{ margin: "0 0 12px", fontSize: 13 }}>
36+
Preview captures the current grid output, including formatted totals.
37+
Regenerate it after changing export settings, then download that exact
38+
file.
39+
</p>
40+
<fieldset style={{ margin: "0 0 12px", minWidth: 0 }}>
41+
<legend>Export columns</legend>
42+
<div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
43+
{exportColumns.map((column) => (
44+
<label key={column.id}>
45+
<input
46+
type="checkbox"
47+
checked={columnIds.includes(column.id)}
48+
onChange={(event) => {
49+
const checked = event.target.checked;
50+
setColumnIds((current) =>
51+
exportColumns
52+
.filter((candidate) =>
53+
candidate.id === column.id
54+
? checked
55+
: current.includes(candidate.id),
56+
)
57+
.map((candidate) => candidate.id),
58+
);
59+
setPreview(null);
60+
}}
61+
/>{" "}
62+
{column.label}
63+
</label>
64+
))}
65+
</div>
66+
</fieldset>
67+
<div
68+
style={{
69+
display: "flex",
70+
flexWrap: "wrap",
71+
alignItems: "center",
72+
gap: 12,
73+
marginBottom: 12,
74+
}}
75+
>
76+
<label>
77+
<input
78+
type="checkbox"
79+
checked={includeHeaders}
80+
onChange={(event) => {
81+
setIncludeHeaders(event.target.checked);
82+
setPreview(null);
83+
}}
84+
/>{" "}
85+
Include headers
86+
</label>
87+
<label>
88+
Delimiter{" "}
89+
<select
90+
value={delimiter}
91+
onChange={(event) => {
92+
setDelimiter(event.target.value);
93+
setPreview(null);
94+
}}
95+
>
96+
<option value=",">Comma</option>
97+
<option value=";">Semicolon</option>
98+
<option value={"\t"}>Tab</option>
99+
</select>
100+
</label>
101+
<PretableButton
102+
disabled={!grid || columnIds.length === 0}
103+
onClick={() => {
104+
grid?.exportCsv({
105+
columnIds,
106+
includeHeaders,
107+
delimiter,
108+
bom: false,
109+
});
110+
}}
111+
>
112+
Preview CSV
113+
</PretableButton>
114+
<PretableButton
115+
disabled={!preview}
116+
onClick={() => {
117+
if (preview) defaultSaveFile(preview, { name: "orders-preview" });
118+
}}
119+
>
120+
Download CSV
121+
</PretableButton>
122+
</div>
123+
<PretableSurface<Order>
124+
ariaLabel="Orders for CSV preview"
125+
columns={columns}
126+
rows={orders}
127+
getRowId={getRowId}
128+
onGridReady={setGrid}
129+
saveFile={setPreview}
130+
toolPanel={false}
131+
viewportHeight={210}
132+
/>
133+
<p role="status" style={{ margin: "12px 0", fontSize: 13 }}>
134+
{columnIds.length === 0
135+
? "Select at least one column to preview."
136+
: preview
137+
? `${preview.rowCount} rows · Preview ready. Download uses this exact file.`
138+
: `${orders.length} rows · Choose export options, then preview the CSV.`}
139+
</p>
140+
{preview && (
141+
<pre
142+
aria-label="CSV preview"
143+
style={{
144+
margin: 0,
145+
padding: 12,
146+
maxWidth: "100%",
147+
overflowX: "auto",
148+
fontSize: 12,
149+
border: "1px solid var(--pt-border-color, #888)",
150+
borderRadius: 4,
151+
}}
152+
>
153+
{preview.text}
154+
</pre>
155+
)}
156+
</div>
157+
);
158+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { numberFormats, type PretableColumn } from "@pretable/react";
2+
3+
import type { Order } from "./data";
4+
5+
export const columns: readonly PretableColumn<Order>[] = [
6+
{
7+
id: "id",
8+
header: "Order",
9+
widthPx: 90,
10+
filterable: false,
11+
sortable: false,
12+
},
13+
{
14+
id: "customer",
15+
header: "Customer",
16+
widthPx: 110,
17+
filterable: false,
18+
sortable: false,
19+
},
20+
{
21+
id: "total",
22+
header: "Total",
23+
type: "number",
24+
numberFormat: numberFormats.money({ currency: "USD" }),
25+
widthPx: 90,
26+
filterable: false,
27+
sortable: false,
28+
},
29+
];
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface Order extends Record<string, unknown> {
2+
id: string;
3+
customer: string;
4+
total: number;
5+
}
6+
7+
export const orders: Order[] = [
8+
{ id: "ORD-101", customer: "Acme, Inc.", total: 1234.5 },
9+
{ id: "ORD-102", customer: 'Beacon "Labs"', total: 99 },
10+
{ id: "ORD-103", customer: "Cedar", total: 0 },
11+
];
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ExportPreviewGrid } from "./ExportPreviewGrid";
2+
3+
export default function Demo() {
4+
return <ExportPreviewGrid />;
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineExample } from "../../../lib/docs/examples/define";
2+
3+
export default defineExample({
4+
title: "Preview a CSV export",
5+
height: 650,
6+
description:
7+
"Choose columns, headers, and a delimiter, preview the grid's CSV output, then download the exact captured file.",
8+
files: ["ExportPreviewGrid.tsx", "columns.ts", "data.ts"],
9+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { readFile } from "node:fs/promises";
2+
import { expect, test, type Page } from "@playwright/test";
3+
4+
async function openPreview(page: Page) {
5+
await page.goto("/docs/grid/export", { waitUntil: "domcontentloaded" });
6+
const example = page.locator("figure").filter({
7+
has: page.locator('a[href="/examples/export-preview.md"]'),
8+
});
9+
await example.scrollIntoViewIfNeeded();
10+
await expect(
11+
example.locator("[data-pretable-hydrated]").first(),
12+
).toHaveAttribute("data-pretable-hydrated", "true", { timeout: 20_000 });
13+
return example;
14+
}
15+
16+
test("CSV preview quotes formatted data and downloads the exact captured file", async ({
17+
page,
18+
}) => {
19+
const example = await openPreview(page);
20+
const downloadButton = example.getByRole("button", { name: "Download CSV" });
21+
await expect(downloadButton).toBeDisabled();
22+
await example.getByRole("button", { name: "Preview CSV" }).click();
23+
const preview = example.getByLabel("CSV preview", { exact: true });
24+
const text = await preview.textContent();
25+
expect(text?.split(/\r?\n/).filter(Boolean)).toEqual([
26+
"Order,Customer,Total",
27+
'ORD-101,"Acme, Inc.","$1,234.50"',
28+
'ORD-102,"Beacon ""Labs""",$99.00',
29+
"ORD-103,Cedar,$0.00",
30+
]);
31+
const pending = page.waitForEvent("download");
32+
await downloadButton.click();
33+
const download = await pending;
34+
expect(download.suggestedFilename()).toMatch(
35+
/^orders-preview-\d{8}T\d{6}Z\.csv$/,
36+
);
37+
const path = await download.path();
38+
expect(path).not.toBeNull();
39+
expect(await readFile(path!, "utf8")).toBe(text);
40+
});
41+
42+
test("CSV settings invalidate old previews and control columns, headers, and delimiters", async ({
43+
page,
44+
}) => {
45+
const example = await openPreview(page);
46+
const generate = example.getByRole("button", { name: "Preview CSV" });
47+
const download = example.getByRole("button", { name: "Download CSV" });
48+
const preview = example.getByLabel("CSV preview", { exact: true });
49+
await generate.click();
50+
await example
51+
.getByRole("checkbox", { name: "Customer", exact: true })
52+
.uncheck();
53+
await expect(preview).toHaveCount(0);
54+
await expect(download).toBeDisabled();
55+
await example.getByRole("checkbox", { name: "Include headers" }).uncheck();
56+
for (const delimiter of [";", "\t"]) {
57+
await example
58+
.getByRole("combobox", { name: "Delimiter", exact: true })
59+
.selectOption(delimiter);
60+
await expect(download).toBeDisabled();
61+
await generate.click();
62+
expect(
63+
(await preview.textContent())?.split(/\r?\n/).filter(Boolean),
64+
).toEqual([
65+
`ORD-101${delimiter}$1,234.50`,
66+
`ORD-102${delimiter}$99.00`,
67+
`ORD-103${delimiter}$0.00`,
68+
]);
69+
}
70+
await example.getByRole("checkbox", { name: "Order", exact: true }).uncheck();
71+
await example.getByRole("checkbox", { name: "Total", exact: true }).uncheck();
72+
await expect(generate).toBeDisabled();
73+
await expect(download).toBeDisabled();
74+
await expect(example).toContainText("Select at least one column to preview.");
75+
await example
76+
.getByRole("checkbox", { name: "Customer", exact: true })
77+
.check();
78+
await generate.click();
79+
await expect(preview).toContainText('"Beacon ""Labs"""');
80+
});
81+
82+
test("CSV example source is complete and preview stays within a mobile page", async ({
83+
page,
84+
request,
85+
}) => {
86+
await page.setViewportSize({ width: 390, height: 844 });
87+
const example = await openPreview(page);
88+
await example.getByRole("button", { name: "Preview CSV" }).click();
89+
await expect(
90+
example.getByLabel("CSV preview", { exact: true }),
91+
).toBeVisible();
92+
expect(
93+
await page.evaluate(
94+
() => document.documentElement.scrollWidth <= innerWidth,
95+
),
96+
).toBe(true);
97+
const response = await request.get("/examples/export-preview.md");
98+
expect(response.ok()).toBe(true);
99+
const source = await response.text();
100+
expect(source).toContain("saveFile={setPreview}");
101+
expect(source).toContain("defaultSaveFile(preview");
102+
});

‎apps/website/lib/docs/examples/demos.generated.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import editorMultilineDemo from "../../../content/examples/editor-multiline/demo
2929
import editorNumberDemo from "../../../content/examples/editor-number/demo";
3030
import editorTextDemo from "../../../content/examples/editor-text/demo";
3131
import exportCsvDemo from "../../../content/examples/export-csv/demo";
32+
import exportPreviewDemo from "../../../content/examples/export-preview/demo";
3233
import firstGridDemo from "../../../content/examples/first-grid/demo";
3334
import gridLevelRenderersDemo from "../../../content/examples/grid-level-renderers/demo";
3435
import groupExpansionControlDemo from "../../../content/examples/group-expansion-control/demo";
@@ -93,6 +94,7 @@ export const exampleDemos: Partial<Record<ExampleId, ComponentType>> = {
9394
"editor-number": editorNumberDemo,
9495
"editor-text": editorTextDemo,
9596
"export-csv": exportCsvDemo,
97+
"export-preview": exportPreviewDemo,
9698
"first-grid": firstGridDemo,
9799
"grid-level-renderers": gridLevelRenderersDemo,
98100
"group-expansion-control": groupExpansionControlDemo,

‎apps/website/lib/docs/examples/registry.generated.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import editorMultiline from "../../../content/examples/editor-multiline/example"
2626
import editorNumber from "../../../content/examples/editor-number/example";
2727
import editorText from "../../../content/examples/editor-text/example";
2828
import exportCsv from "../../../content/examples/export-csv/example";
29+
import exportPreview from "../../../content/examples/export-preview/example";
2930
import firstGrid from "../../../content/examples/first-grid/example";
3031
import gridLevelRenderers from "../../../content/examples/grid-level-renderers/example";
3132
import groupExpansionControl from "../../../content/examples/group-expansion-control/example";
@@ -160,6 +161,10 @@ export const exampleRegistry = {
160161
meta: exportCsv,
161162
hasDemo: true,
162163
},
164+
"export-preview": {
165+
meta: exportPreview,
166+
hasDemo: true,
167+
},
163168
"first-grid": {
164169
meta: firstGrid,
165170
hasDemo: true,

‎docs/superpowers/plans/2026-09-11-docs-workflow-examples.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525

2626
## 3. CSV export — blove/export-preview-example
2727

28-
- [ ] Add `export-preview` example showing actual exported content from public API with selectable columns, headers, delimiter, and formatted output (the API has no raw/formatted toggle). Download must match preview using the same options and data.
29-
- [ ] Embed beside export options guidance in `apps/website/content/docs/grid/export.mdx`. Keep existing selected-row download demo.
30-
- [ ] Test meaningful CSV content changes, quoting, actual download content, source availability, and mobile fit.
28+
- [x] Add `export-preview` example showing actual exported content from public API with selectable columns, headers, delimiter, and formatted output (the API has no raw/formatted toggle). Download must match preview using the same options and data.
29+
- [x] Embed beside export options guidance in `apps/website/content/docs/grid/export.mdx`. Keep existing selected-row download demo.
30+
- [x] Test meaningful CSV content changes, quoting, actual download content, source availability, and mobile fit.
3131
- [ ] Review, validate, separate PR, green merge, production verification.
3232

3333
## 4. Paste — blove/paste-validation-example

0 commit comments

Comments
 (0)