From 1bfb2bedddd684f698005f532d726fe70fa0e3c6 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 19:08:37 +0900 Subject: [PATCH 01/14] feat(data-table): add CSV export --- .changeset/bright-csv-exporter.md | 12 ++ docs/api/use-csv-exporter.md | 108 ++++++++++ docs/components/data-table.md | 17 ++ .../pages/showcase/data-table-lab/page.tsx | 12 ++ .../src/pages/showcase/data-table/page.tsx | 36 +++- .../core/src/components/csv-exporter/i18n.ts | 45 ++++ .../core/src/components/csv-exporter/index.ts | 10 + .../core/src/components/csv-exporter/types.ts | 62 ++++++ .../csv-exporter/use-csv-exporter.test.tsx | 103 ++++++++++ .../csv-exporter/use-csv-exporter.ts | 194 ++++++++++++++++++ .../components/data-table/csv-export.test.tsx | 73 +++++++ .../src/components/data-table/csv-export.tsx | 158 ++++++++++++++ .../src/components/data-table/data-table.tsx | 9 +- .../core/src/components/data-table/index.ts | 1 + .../src/components/data-table/toolbar.tsx | 23 ++- packages/core/src/index.ts | 11 + 16 files changed, 867 insertions(+), 7 deletions(-) create mode 100644 .changeset/bright-csv-exporter.md create mode 100644 docs/api/use-csv-exporter.md create mode 100644 packages/core/src/components/csv-exporter/i18n.ts create mode 100644 packages/core/src/components/csv-exporter/index.ts create mode 100644 packages/core/src/components/csv-exporter/types.ts create mode 100644 packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx create mode 100644 packages/core/src/components/csv-exporter/use-csv-exporter.ts create mode 100644 packages/core/src/components/data-table/csv-export.test.tsx create mode 100644 packages/core/src/components/data-table/csv-export.tsx diff --git a/.changeset/bright-csv-exporter.md b/.changeset/bright-csv-exporter.md new file mode 100644 index 000000000..bc37323f1 --- /dev/null +++ b/.changeset/bright-csv-exporter.md @@ -0,0 +1,12 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `useCsvExporter` for client-side, cursor-paginated CSV downloads with progress and cancellation. + +```tsx +const exporter = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); +; +``` + +`DataTable.CSVExport` provides a filename dialog and export progress UI. diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md new file mode 100644 index 000000000..f6272b3d4 --- /dev/null +++ b/docs/api/use-csv-exporter.md @@ -0,0 +1,108 @@ +--- +title: useCsvExporter +description: Fetch all cursor-paginated rows and download them as a CSV file with progress and cancellation state +--- + +# useCsvExporter + +`useCsvExporter` fetches every page from a Relay-style cursor connection, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExport`](../components/data-table.md#csv-export). It is client-agnostic: the calling application implements the `fetcher` with its own data client. + +## Import + +```tsx +import { + useCsvExporter, + type CsvCursorConnection, + type CsvExportColumn, +} from "@tailor-platform/app-shell"; +``` + +## Usage + +```tsx +const exporter = useCsvExporter({ + defaultFilename: "products.csv", + columns, + fetcher: async ({ first, after, signal }) => { + const result = await client + .query( + ProductsCsvQuery, + { first, after, order, query }, + { + requestPolicy: "network-only", + fetchOptions: { signal }, + }, + ) + .toPromise(); + + if (result.error) throw result.error; + return result.data?.products; + }, +}); + +; +``` + +The fetcher receives the cursor parameters for one page. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. + +Include `total` in the connection query to show a determinate progress bar: + +```graphql +products(first: $first, after: $after, order: $order, query: $query) { + total + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + name + } + } +} +``` + +Without `total`, the progress dialog shows the number of rows fetched but not a percentage. + +## Options + +| Option | Type | Description | +| ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | +| `defaultFilename` | `string` | Initial file name shown in the dialog. A `.csv` suffix is added when omitted. | +| `columns` | `CsvExportColumn[] \| Column[]` | Explicit CSV columns, or DataTable columns to derive them from. | +| `fetcher` | `CsvCursorFetcher` | Fetches one cursor page. Required. | +| `pageSize` | `number` | Rows requested per page. Defaults to `1000`. | + +## DataTable column derivation + +Pass DataTable's `Column[]` directly when CSV headers and raw cell values should follow the table definition. + +```tsx +const exporter = useCsvExporter({ + defaultFilename: "products.csv", + columns, + fetcher, +}); +``` + +A labelled column resolves each CSV value in this order: + +1. `accessor(row)` +2. `row[id]` +3. a primitive (`string`, `number`, `boolean`, or `bigint`) returned by `render(row)` + +Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table. + +## Return value + +| Property | Type | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `defaultFilename` | `string` | Suggested download name. | +| `exportCsv(filename?)` | `(filename?: string) => Promise` | Starts an export. Uses `defaultFilename` when no name is given. | +| `cancel()` | `() => void` | Aborts the in-flight fetcher request. | +| `phase` | `"idle" \| "fetching" \| "serializing" \| "downloading" \| "success" \| "error" \| "cancelled"` | Current export phase. | +| `progress` | `{ completed: number; total: number \| null }` | Fetched row count and optional total. | +| `error` | `Error \| null` | Last export error. | + +CSV cells are escaped using RFC 4180-compatible quoting, UTF-8 BOM is included for spreadsheet compatibility, and formula-like cell values are escaped before download. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 0ebc09f02..9853d7b27 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -16,6 +16,7 @@ import { DataTable, useDataTable, useDataTableContext, + useCsvExporter, useCollectionVariables, createColumnHelper, type Column, @@ -152,6 +153,7 @@ function JournalsPage() { | `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | | `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | +| `DataTable.CSVExport` | Standard CSV download button and filename/progress dialog. Pass an exporter from [`useCsvExporter`](../api/use-csv-exporter.md) through `exporter`. | ### `DataTable.Root` Props @@ -194,6 +196,21 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi ``` +### `DataTable.CSVExport` Props + +| Prop | Type | Description | +| ---------- | ------------- | --------------------------------------------------------------------------- | +| `exporter` | `CsvExporter` | Return value from [`useCsvExporter`](../api/use-csv-exporter.md). Required. | + +`DataTable.CSVExport` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. Place it as a direct `DataTable.Toolbar` child to render it immediately before the built-in Columns control: + +```tsx + + + + +``` + ### `DataTable.Pagination` Props | Prop | Type | Default | Description | diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index e20955552..6e0d27881 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx @@ -4,6 +4,7 @@ import { Button, DataTable, useDataTable, + useCsvExporter, useCollectionVariables, createColumnHelper, type AppShellPageProps, @@ -518,6 +519,16 @@ const DataTableLabPage = () => { rowActions, }); + const exporter = useCsvExporter({ + defaultFilename: "invoices.csv", + columns: settingsTable.columns, + fetcher: async () => ({ + edges: rows.map((node) => ({ node })), + pageInfo: { hasNextPage: false, endCursor: null }, + total: rows.length, + }), + }); + // Expandable rows next to everything they have to survive: selection, a // left-pinned column, row actions and horizontal scroll. `getLabel` // returns the bare record identity — the accessible names ("Expand row @@ -561,6 +572,7 @@ const DataTableLabPage = () => { + diff --git a/examples/vite-app/src/pages/showcase/data-table/page.tsx b/examples/vite-app/src/pages/showcase/data-table/page.tsx index 3f38e0a74..aef651f42 100644 --- a/examples/vite-app/src/pages/showcase/data-table/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table/page.tsx @@ -4,6 +4,7 @@ import { Badge, DataTable, useDataTable, + useCsvExporter, useCollectionVariables, createColumnHelper, type CollectionControl, @@ -206,26 +207,30 @@ const statusVariant = (status: InvoiceStatus) => : ("neutral" as const); const columns = [ - column({ label: "Invoice", render: (row) => row.id }), + column({ id: "id", label: "Invoice", render: (row) => row.id }), // uuid → text input, eq only. column({ + id: "externalId", label: "Ref", render: (row) => {row.externalId.slice(0, 8)}…, filter: { field: "externalId", type: "uuid" }, }), column({ + id: "customer", label: "Customer", render: (row) => row.customer, sort: { field: "customer", type: "string" }, filter: { field: "customer", type: "string" }, }), column({ + id: "amount", label: "Amount", render: (row) => moneyFormatter.format(row.amount), sort: { field: "amount", type: "number" }, filter: { field: "amount", type: "number" }, }), column({ + id: "status", label: "Status", render: (row) => {row.status}, filter: { @@ -236,6 +241,7 @@ const columns = [ }), // boolean → is / is not, True/False picker. column({ + id: "recurring", label: "Recurring", render: (row) => ( @@ -245,6 +251,7 @@ const columns = [ filter: { field: "recurring", type: "boolean" }, }), column({ + id: "dueDate", label: "Due date", render: (row) => dateFormatter.format(new Date(`${row.dueDate}T00:00:00`)), sort: { field: "dueDate", type: "date" }, @@ -254,6 +261,7 @@ const columns = [ }), // datetime → full numeric operator set; value is a strict ISO datetime string. column({ + id: "createdAt", label: "Created", render: (row) => dateTimeFormatter.format(new Date(row.createdAt)), sort: { field: "createdAt", type: "date" }, @@ -261,6 +269,7 @@ const columns = [ }), // time → native time input, "HH:mm". column({ + id: "reminderAt", label: "Reminder", render: (row) => row.reminderAt, filter: { field: "reminderAt", type: "time" }, @@ -334,10 +343,33 @@ function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => Re }, [variables, variablesKey]); const table = useDataTable({ columns, data, loading, control }); + const exporter = useCsvExporter({ + defaultFilename: "invoices.csv", + columns, + pageSize: 10, + fetcher: async ({ first, after }) => { + const page = await queryInvoices({ + query: variables.query, + order: variables.order, + pagination: { first, after }, + }); + return { + edges: page.rows.map((node) => ({ node })), + pageInfo: page.pageInfo ?? { + hasNextPage: false, + endCursor: null, + }, + total: page.total ?? null, + }; + }, + }); return ( - {toolbar(control)} + + {toolbar(control)} + + diff --git a/packages/core/src/components/csv-exporter/i18n.ts b/packages/core/src/components/csv-exporter/i18n.ts new file mode 100644 index 000000000..08332c868 --- /dev/null +++ b/packages/core/src/components/csv-exporter/i18n.ts @@ -0,0 +1,45 @@ +import { defineI18nLabels } from "@/hooks/i18n"; + +export const csvExporterLabels = defineI18nLabels({ + en: { + noRows: "No data to export", + exportComplete: (props: { count: number }) => `${props.count.toLocaleString()} rows exported`, + exportFailed: "Failed to export CSV", + exportCancelled: "CSV export cancelled", + dialogTitle: "Download CSV", + filename: "File name", + download: "Download", + csvExport: "CSV Export", + exporting: "Exporting CSV", + fetchingRows: (props: { completed: number; total: number | null }) => + props.total == null + ? `${props.completed.toLocaleString()} rows fetched` + : `${props.completed.toLocaleString()} / ${props.total.toLocaleString()} rows fetched`, + creatingFile: "Creating CSV file…", + cancel: "Cancel", + retry: "Retry", + invalidFilename: "Enter a file name", + }, + ja: { + noRows: "エクスポートするデータがありません", + exportComplete: (props: { count: number }) => + `${props.count.toLocaleString()} 行をエクスポートしました`, + exportFailed: "CSV のエクスポートに失敗しました", + exportCancelled: "CSV のエクスポートをキャンセルしました", + dialogTitle: "CSV をダウンロード", + filename: "ファイル名", + download: "ダウンロード", + csvExport: "CSVエクスポート", + exporting: "CSV をエクスポート中", + fetchingRows: (props: { completed: number; total: number | null }) => + props.total == null + ? `${props.completed.toLocaleString()} 行を取得済み` + : `${props.completed.toLocaleString()} / ${props.total.toLocaleString()} 行を取得中`, + creatingFile: "CSV ファイルを作成中…", + cancel: "キャンセル", + retry: "再試行", + invalidFilename: "ファイル名を入力してください", + }, +}); + +export const useCsvExporterT = csvExporterLabels.useT; diff --git a/packages/core/src/components/csv-exporter/index.ts b/packages/core/src/components/csv-exporter/index.ts new file mode 100644 index 000000000..2c3198fc2 --- /dev/null +++ b/packages/core/src/components/csv-exporter/index.ts @@ -0,0 +1,10 @@ +export { useCsvExporter } from "./use-csv-exporter"; +export type { + CsvCursorConnection, + CsvCursorFetcher, + CsvExportCell, + CsvExportColumn, + CsvExportPhase, + CsvExporter, + UseCsvExporterOptions, +} from "./types"; diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts new file mode 100644 index 000000000..23f904bcd --- /dev/null +++ b/packages/core/src/components/csv-exporter/types.ts @@ -0,0 +1,62 @@ +import type { Column } from "@/components/data-table/types"; + +export type CsvExportCell = string | number | boolean | bigint | null | undefined; + +/** A CSV column independent of any rendered table. */ +export interface CsvExportColumn { + header: string; + value: (row: TRow) => CsvExportCell; +} + +/** A Relay-style cursor page returned by a CSV export data source. */ +export interface CsvCursorConnection { + edges: readonly { node: TRow }[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + /** Enables a determinate progress bar when the backend provides a count. */ + total?: number | null; +} + +export type CsvCursorFetcher = (pagination: { + first: number; + after: string | null; + signal: AbortSignal; +}) => Promise | null | undefined>; + +export type CsvExportColumnSource> = + | readonly CsvExportColumn[] + | readonly Column[]; + +export type CsvExportPhase = + | "idle" + | "fetching" + | "serializing" + | "downloading" + | "success" + | "error" + | "cancelled"; + +export interface UseCsvExporterOptions> { + /** Initial filename displayed in the export dialog. The .csv suffix is added when omitted. */ + defaultFilename: string; + /** Explicit CSV columns, or DataTable columns to derive them from. */ + columns: CsvExportColumnSource; + /** Fetches one cursor page. The hook fetches every page. */ + fetcher: CsvCursorFetcher; + /** Rows requested per cursor page. Defaults to 1000. */ + pageSize?: number; +} + +export interface CsvExporter { + defaultFilename: string; + exportCsv: (filename?: string) => Promise; + cancel: () => void; + phase: CsvExportPhase; + progress: { + completed: number; + total: number | null; + }; + error: Error | null; +} diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx new file mode 100644 index 000000000..5649038e3 --- /dev/null +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { Column } from "@/components/data-table/types"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { useCsvExporter } from "./use-csv-exporter"; + +const wrapper = createAppShellWrapper("en"); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("useCsvExporter", () => { + it("fetches every cursor page and derives CSV columns from DataTable columns", async () => { + type Row = { id: string; name: string; formula: string }; + const columns: Column[] = [ + { label: "Name", render: (row) => row.name }, + { id: "formula", label: "Formula", render: (row) => row.formula }, + { id: "actions", render: () => }, + ]; + const fetcher = vi + .fn() + .mockResolvedValueOnce({ + edges: [{ node: { id: "1", name: "Alice", formula: "=SUM(A1:A2)" } }], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + total: 2, + }) + .mockResolvedValueOnce({ + edges: [{ node: { id: "2", name: "Bob", formula: "safe" } }], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + total: 2, + }); + const downloaded: Blob[] = []; + const createObjectURL = vi.fn((blob: Blob) => { + downloaded.push(blob); + return "blob:csv"; + }); + vi.stubGlobal("URL", { createObjectURL, revokeObjectURL: vi.fn() }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const { result } = renderHook( + () => + useCsvExporter({ + defaultFilename: "products", + columns, + fetcher, + }), + { wrapper }, + ); + + await act(() => result.current.exportCsv()); + + expect(fetcher).toHaveBeenNthCalledWith(1, { + first: 1000, + after: null, + signal: expect.any(AbortSignal), + }); + expect(fetcher).toHaveBeenNthCalledWith(2, { + first: 1000, + after: "cursor-1", + signal: expect.any(AbortSignal), + }); + expect(result.current.phase).toBe("success"); + expect(result.current.progress).toEqual({ completed: 2, total: 2 }); + + expect(await downloaded[0]!.text()).toBe( + '\uFEFFName,Formula\r\nAlice,"\'=SUM(A1:A2)"\r\nBob,safe', + ); + }); + + it("cancels an in-flight fetch", async () => { + const fetcher = vi.fn( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }), + ); + const { result } = renderHook( + () => + useCsvExporter({ + defaultFilename: "products.csv", + columns: [{ header: "Name", value: () => "Alice" }], + fetcher, + }), + { wrapper }, + ); + + let exportPromise!: Promise; + act(() => { + exportPromise = result.current.exportCsv(); + }); + act(() => result.current.cancel()); + await act(async () => { + await exportPromise; + }); + + await waitFor(() => { + expect(result.current.phase).toBe("cancelled"); + }); + }); +}); diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts new file mode 100644 index 000000000..51d1cb243 --- /dev/null +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -0,0 +1,194 @@ +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { unparse } from "papaparse"; +import { getCellValue } from "@/components/data-table/cell-renderers"; +import type { Column } from "@/components/data-table/types"; +import { useToast } from "@/hooks/use-toast"; +import { useCsvExporterT } from "./i18n"; +import type { + CsvCursorConnection, + CsvExportCell, + CsvExportColumn, + CsvExportColumnSource, + CsvExporter, + UseCsvExporterOptions, +} from "./types"; + +const DEFAULT_PAGE_SIZE = 1000; + +function primitiveRenderedValue(value: ReactNode): CsvExportCell { + return typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ? value + : undefined; +} + +function toCsvCell(value: unknown): CsvExportCell { + if ( + value == null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return value; + } + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); + if (Array.isArray(value)) + return value + .map(toCsvCell) + .filter((item) => item != null) + .join(", "); + return undefined; +} + +function isCsvExportColumn>( + column: CsvExportColumn | Column, +): column is CsvExportColumn { + return "value" in column && typeof column.value === "function"; +} + +function resolveColumns>( + columns: CsvExportColumnSource, +): CsvExportColumn[] { + if (columns.length === 0 || isCsvExportColumn(columns[0])) { + return columns as CsvExportColumn[]; + } + + return (columns as readonly Column[]).flatMap((column) => { + if (!column.label) return []; + return { + header: column.label, + value: (row: TRow) => { + const raw = getCellValue(row, column); + return toCsvCell(raw !== undefined ? raw : primitiveRenderedValue(column.render?.(row))); + }, + }; + }); +} + +function normalizeFilename(filename: string): string { + const normalized = filename.trim().replace(/[\\/]/g, "_"); + if (!normalized) throw new Error("A CSV filename is required"); + return normalized.toLowerCase().endsWith(".csv") ? normalized : `${normalized}.csv`; +} + +function downloadCsv(csv: string, filename: string) { + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +/** + * Fetch every cursor page, serialize its rows as CSV, and expose progress for + * a UI such as `DataTable.CSVExport`. The data client stays outside this hook: + * `fetcher` only needs to return a Relay-style cursor connection. + */ +export function useCsvExporter>({ + defaultFilename, + columns: columnSource, + fetcher, + pageSize = DEFAULT_PAGE_SIZE, +}: UseCsvExporterOptions): CsvExporter { + const toast = useToast(); + const t = useCsvExporterT(); + const columns = useMemo(() => resolveColumns(columnSource), [columnSource]); + const abortControllerRef = useRef(null); + const [phase, setPhase] = useState("idle"); + const [progress, setProgress] = useState({ completed: 0, total: null }); + const [error, setError] = useState(null); + + const cancel = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); + + const exportCsv = useCallback( + async (requestedFilename = defaultFilename) => { + if (abortControllerRef.current) return; + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + setPhase("fetching"); + setProgress({ completed: 0, total: null }); + setError(null); + + try { + const filename = normalizeFilename(requestedFilename); + const rows: TRow[] = []; + const seenCursors = new Set(); + let after: string | null = null; + let total: number | null = null; + + for (;;) { + const connection = await fetcher({ + first: pageSize, + after, + signal: abortController.signal, + }); + if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); + if (!connection) break; + + rows.push(...connection.edges.map(({ node }) => node)); + total ??= connection.total ?? null; + setProgress({ completed: rows.length, total }); + + if (!connection.pageInfo.hasNextPage) break; + const next = connection.pageInfo.endCursor; + if (next == null || seenCursors.has(next)) { + throw new Error("CSV export pagination did not advance"); + } + seenCursors.add(next); + after = next; + } + + if (rows.length === 0) { + setPhase("success"); + toast.info(t("noRows")); + return; + } + + setPhase("serializing"); + const csv = unparse( + { + fields: columns.map((column) => column.header), + data: rows.map((row) => columns.map((column) => column.value(row) ?? "")), + }, + { newline: "\r\n", escapeFormulae: true }, + ); + + if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); + setPhase("downloading"); + downloadCsv(csv, filename); + setPhase("success"); + toast.success(t("exportComplete", { count: rows.length })); + } catch (caught) { + if ( + abortController.signal.aborted || + (caught instanceof DOMException && caught.name === "AbortError") + ) { + setPhase("cancelled"); + toast.info(t("exportCancelled")); + } else { + const exportError = caught instanceof Error ? caught : new Error(String(caught)); + setError(exportError); + setPhase("error"); + toast.error(t("exportFailed")); + } + } finally { + abortControllerRef.current = null; + } + }, + [columns, defaultFilename, fetcher, pageSize, t, toast], + ); + + return { defaultFilename, exportCsv, cancel, phase, progress, error }; +} + +export type { CsvCursorConnection }; diff --git a/packages/core/src/components/data-table/csv-export.test.tsx b/packages/core/src/components/data-table/csv-export.test.tsx new file mode 100644 index 000000000..463ee4405 --- /dev/null +++ b/packages/core/src/components/data-table/csv-export.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { DataTable } from "./data-table"; +import { DataTableCSVExport } from "./csv-export"; +import type { CsvExporter } from "@/components/csv-exporter"; + +const wrapper = createAppShellWrapper("en"); + +afterEach(() => { + cleanup(); +}); + +describe("DataTable.CSVExport", () => { + const idleExporter: CsvExporter = { + defaultFilename: "products.csv", + exportCsv: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn(), + phase: "idle", + progress: { completed: 0, total: null }, + error: null, + }; + + it("moves a direct toolbar child beside column controls", () => { + const { container } = render( + + Filters + + , + { wrapper }, + ); + + const toolbar = container.querySelector('[data-slot="data-table-toolbar"]')!; + expect(toolbar.children).toHaveLength(2); + expect(toolbar.children[0]?.textContent).toContain("Filters"); + expect(toolbar.children[1]?.textContent).toContain("CSV Export"); + }); + + it("can reopen an in-progress export dialog", async () => { + const user = userEvent.setup(); + const exporter: CsvExporter = { ...idleExporter, phase: "fetching" }; + + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + expect(screen.getByRole("dialog")).toBeDefined(); + + await user.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + expect(screen.getByRole("dialog")).toBeDefined(); + }); + + it("uses the dialog filename when starting an export", async () => { + const user = userEvent.setup(); + const exporter: CsvExporter = { + ...idleExporter, + exportCsv: vi.fn().mockResolvedValue(undefined), + }; + + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + const filename = screen.getByLabelText("File name"); + await user.clear(filename); + await user.type(filename, "all-products"); + await user.click(screen.getByRole("button", { name: "Download" })); + + expect(exporter.exportCsv).toHaveBeenCalledWith("all-products"); + }); +}); diff --git a/packages/core/src/components/data-table/csv-export.tsx b/packages/core/src/components/data-table/csv-export.tsx new file mode 100644 index 000000000..834f38330 --- /dev/null +++ b/packages/core/src/components/data-table/csv-export.tsx @@ -0,0 +1,158 @@ +import { useEffect, useId, useState } from "react"; +import { Download } from "lucide-react"; +import { Button } from "@/components/button"; +import { Dialog } from "@/components/dialog"; +import { Input } from "@/components/input"; +import { Spinner } from "@/components/spinner"; +import { useCsvExporterT } from "@/components/csv-exporter/i18n"; +import type { CsvExporter } from "@/components/csv-exporter"; + +export interface DataTableCSVExportProps { + exporter: CsvExporter; +} + +/** + * Standard CSV export button and progress dialog. Supply the stateful value + * returned by `useCsvExporter` through `exporter`. + */ +export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { + const t = useCsvExporterT(); + const filenameId = useId(); + const [open, setOpen] = useState(false); + const [filename, setFilename] = useState(exporter.defaultFilename); + const [filenameError, setFilenameError] = useState(null); + const isExporting = + exporter.phase === "fetching" || + exporter.phase === "serializing" || + exporter.phase === "downloading"; + const total = exporter.progress.total; + const progress = + total != null && total > 0 ? Math.min(100, (exporter.progress.completed / total) * 100) : null; + + useEffect(() => { + if (exporter.phase === "success") setOpen(false); + }, [exporter.phase]); + + const onOpenChange = (nextOpen: boolean) => { + if (nextOpen && !isExporting) { + setFilename(exporter.defaultFilename); + setFilenameError(null); + } + setOpen(nextOpen); + }; + + const startExport = () => { + if (!filename.trim()) { + setFilenameError(t("invalidFilename")); + return; + } + setFilenameError(null); + void exporter.exportCsv(filename); + }; + + return ( + +
+ }> + + {t("csvExport")} + +
+ + + {isExporting ? t("exporting") : t("dialogTitle")} + + + {isExporting ? ( +
+ {exporter.phase === "fetching" ? ( + <> +

{t("fetchingRows", exporter.progress)}

+ + + ) : ( +
+ +

{t("creatingFile")}

+
+ )} +
+ ) : ( +
{ + event.preventDefault(); + startExport(); + }} + > + + { + setFilename(event.target.value); + setFilenameError(null); + }} + aria-invalid={filenameError != null} + aria-describedby={filenameError ? `${filenameId}-error` : undefined} + /> + {filenameError && ( + + )} + {exporter.error && ( +

+ {exporter.error.message} +

+ )} +
+ )} + + + {isExporting ? ( + + ) : ( + <> + }>{t("cancel")} + + + )} + +
+
+ ); +} + +function CsvExportProgress({ label, value }: { label: string; value: number | null }) { + if (value == null) { + return ( +
+
+
+ ); + } + + return ( + + ); +} diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 7bd58e3c7..20dcbe30b 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -43,6 +43,7 @@ import { getVisibleFilterOperators, } from "./toolbar"; import { DataTablePagination } from "./pagination"; +import { DataTableCSVExport } from "./csv-export"; export type { DataTablePaginationProps } from "./pagination"; // Fallback row count when no pageSize is configured (static / uncontrolled tables) @@ -1880,7 +1881,8 @@ export const DataTable = { /** * Container for toolbar content (filters, search, etc.). Place inside * `DataTable.Root`, before `DataTable.Table`. Pass `columnSettings` to render - * the built-in "Columns" control (show/hide, reorder, pin) at the top-right. + * the built-in Columns control at the top-right. A direct `CSVExport` child + * is rendered immediately before it. */ Toolbar: DataTableToolbar, /** @@ -1911,4 +1913,9 @@ export const DataTable = { * `totalPages` is non-null (i.e. the backend returns a `total` count). */ Pagination: DataTablePagination, + /** + * Standard CSV export button and progress dialog. Pass the result of + * `useCsvExporter()` through its `exporter` prop. + */ + CSVExport: DataTableCSVExport, } as const; diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index 27c3ad969..a28269464 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -1,5 +1,6 @@ // DataTable compound component export { DataTable, type DataTablePaginationProps, type DataTableRootProps } from "./data-table"; +export { type DataTableCSVExportProps } from "./csv-export"; export { useDataTable } from "./use-data-table"; export { useDataTableContext, type DataTableContextValue } from "./data-table-context"; diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index d83455e98..d22a870aa 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; import { Popover } from "@base-ui/react/popover"; import { ChevronDown, Filter as FilterIcon, X, Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -13,6 +13,7 @@ import { Tooltip } from "@/components/tooltip"; import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; import { DataTableColumnSettings } from "./column-settings"; +import { DataTableCSVExport } from "./csv-export"; import { useDataTableContext } from "./data-table-context"; import { isTemporalFilterType, isTemporalFilterValueValid } from "./filter-value-utils"; import { useDataTableT } from "./i18n"; @@ -45,6 +46,14 @@ function DataTableToolbar({ columnSettings?: boolean; className?: string; }) { + const toolbarChildren = Children.toArray(children); + const csvExports = toolbarChildren.filter( + (child) => isValidElement(child) && child.type === DataTableCSVExport, + ); + const content = toolbarChildren.filter( + (child) => !isValidElement(child) || child.type !== DataTableCSVExport, + ); + return (
{/* Left group keeps children in their original vertical stack (so existing - toolbars are visually unchanged); the settings button sits top-right. */} -
{children}
- {columnSettings && } + toolbars are visually unchanged). Direct CSV exports are controls, not + content, so they sit beside the fixed column-settings control. */} +
{content}
+ {(csvExports.length > 0 || columnSettings) && ( +
+ {csvExports} + {columnSettings && } +
+ )}
); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9148a70f1..790949edf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -255,6 +255,16 @@ export { type ParsedRow, type InferCsvRow, } from "./components/csv-importer"; +export { + useCsvExporter, + type CsvCursorConnection, + type CsvCursorFetcher, + type CsvExportCell, + type CsvExportColumn, + type CsvExportPhase, + type CsvExporter, + type UseCsvExporterOptions, +} from "./components/csv-exporter"; // Collection (shared across DataTable, Kanban, Gantt, etc.) export { @@ -305,6 +315,7 @@ export { type UseDataTableReturn, type MetadataFieldOptions, type DataTableContextValue, + type DataTableCSVExportProps, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; export { useURLCollectionVariables, withURLCollectionState } from "./lib/collection-url-state"; From 5d52b51d6a9594a551bfe8159a153c946213eb28 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 21:01:50 +0900 Subject: [PATCH 02/14] fix(csv-exporter): require raw table column values --- docs/api/use-csv-exporter.md | 8 +---- .../core/src/components/csv-exporter/types.ts | 13 +++++++- .../csv-exporter/use-csv-exporter.test.tsx | 33 ++++++++++++++++--- .../csv-exporter/use-csv-exporter.ts | 14 ++------ .../components/data-table/field-helpers.ts | 9 +++-- 5 files changed, 49 insertions(+), 28 deletions(-) diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md index f6272b3d4..eb2d62b61 100644 --- a/docs/api/use-csv-exporter.md +++ b/docs/api/use-csv-exporter.md @@ -86,13 +86,7 @@ const exporter = useCsvExporter({ }); ``` -A labelled column resolves each CSV value in this order: - -1. `accessor(row)` -2. `row[id]` -3. a primitive (`string`, `number`, `boolean`, or `bigint`) returned by `render(row)` - -Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table. +A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table. ## Return value diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts index 23f904bcd..f2efaf630 100644 --- a/packages/core/src/components/csv-exporter/types.ts +++ b/packages/core/src/components/csv-exporter/types.ts @@ -25,9 +25,20 @@ export type CsvCursorFetcher = (pagination: { signal: AbortSignal; }) => Promise | null | undefined>; +type CsvExportableDataTableColumn> = + | (Column & { label?: undefined }) + | (Column & { + label: string; + id: string; + }) + | (Column & { + label: string; + accessor: (row: TRow) => unknown; + }); + export type CsvExportColumnSource> = | readonly CsvExportColumn[] - | readonly Column[]; + | readonly CsvExportableDataTableColumn[]; export type CsvExportPhase = | "idle" diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx index 5649038e3..9cabe06fe 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -1,11 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import type { Column } from "@/components/data-table/types"; +import { createColumnHelper } from "@/components/data-table/field-helpers"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { useCsvExporter } from "./use-csv-exporter"; +import type { CsvCursorFetcher, UseCsvExporterOptions } from "./types"; const wrapper = createAppShellWrapper("en"); +type ExportTypeTestRow = { name: string }; +const emptyExportTypeTestFetcher: CsvCursorFetcher = async () => null; + afterEach(() => { cleanup(); vi.restoreAllMocks(); @@ -13,12 +17,31 @@ afterEach(() => { }); describe("useCsvExporter", () => { + it("requires a raw value source for labelled DataTable columns", () => { + const { column } = createColumnHelper(); + const valid: UseCsvExporterOptions = { + defaultFilename: "rows.csv", + columns: [column({ id: "name", label: "Name", render: (row) => row.name })], + fetcher: emptyExportTypeTestFetcher, + }; + expect(valid.columns).toHaveLength(1); + + const invalid: UseCsvExporterOptions = { + defaultFilename: "rows.csv", + // @ts-expect-error Labelled DataTable columns need `id` or `accessor` for CSV export. + columns: [column({ label: "Name", render: (row) => row.name })], + fetcher: emptyExportTypeTestFetcher, + }; + expect(invalid).toBeDefined(); + }); + it("fetches every cursor page and derives CSV columns from DataTable columns", async () => { type Row = { id: string; name: string; formula: string }; - const columns: Column[] = [ - { label: "Name", render: (row) => row.name }, - { id: "formula", label: "Formula", render: (row) => row.formula }, - { id: "actions", render: () => }, + const { column } = createColumnHelper(); + const columns = [ + column({ id: "name", label: "Name", render: (row) => row.name }), + column({ id: "formula", label: "Formula", render: (row) => row.formula }), + column({ render: () => }), ]; const fetcher = vi .fn() diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index 51d1cb243..8f418046d 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { unparse } from "papaparse"; import { getCellValue } from "@/components/data-table/cell-renderers"; import type { Column } from "@/components/data-table/types"; @@ -15,15 +15,6 @@ import type { const DEFAULT_PAGE_SIZE = 1000; -function primitiveRenderedValue(value: ReactNode): CsvExportCell { - return typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ? value - : undefined; -} - function toCsvCell(value: unknown): CsvExportCell { if ( value == null || @@ -61,8 +52,7 @@ function resolveColumns>( return { header: column.label, value: (row: TRow) => { - const raw = getCellValue(row, column); - return toCsvCell(raw !== undefined ? raw : primitiveRenderedValue(column.render?.(row))); + return toCsvCell(getCellValue(row, column)); }, }; }); diff --git a/packages/core/src/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts index cd7f11cfd..602dfb90e 100644 --- a/packages/core/src/components/data-table/field-helpers.ts +++ b/packages/core/src/components/data-table/field-helpers.ts @@ -110,7 +110,10 @@ function inferColumns< * `column({ ...infer("stock"), type: "number" })` type-safe without * requiring an explicit accessor cast. */ -export type InferredColumn> = ColumnBase; +export type InferredColumn> = ColumnBase & { + id: string; + label: string; +}; /** * Per-field column factory returned by `inferColumns(tableMetadata)`. @@ -143,7 +146,7 @@ export interface ColumnHelper> { * column({ label: "Actions", render: (row) => }) * ``` */ - column: (options: Column) => Column; + column: >(options: TColumn) => TColumn; /** * Bind table metadata once and return a per-field column factory. * @@ -190,7 +193,7 @@ export interface ColumnHelper> { */ export function createColumnHelper>(): ColumnHelper { return { - column: (options: Column) => column(options), + column: >(options: TColumn) => ({ ...options }), inferColumns: (tableMetadata: TTable) => inferColumns(tableMetadata), }; From f4f0c462c72d256bb039d58edc79db6691b5dadd Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 21:08:40 +0900 Subject: [PATCH 03/14] refactor(csv-exporter): close dialog from export result --- docs/api/use-csv-exporter.md | 16 ++++++++-------- .../core/src/components/csv-exporter/types.ts | 3 ++- .../csv-exporter/use-csv-exporter.test.tsx | 2 +- .../components/csv-exporter/use-csv-exporter.ts | 6 ++++-- .../components/data-table/csv-export.test.tsx | 4 ++-- .../src/components/data-table/csv-export.tsx | 14 +++++--------- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md index eb2d62b61..5de5def66 100644 --- a/docs/api/use-csv-exporter.md +++ b/docs/api/use-csv-exporter.md @@ -90,13 +90,13 @@ A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScrip ## Return value -| Property | Type | Description | -| ---------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `defaultFilename` | `string` | Suggested download name. | -| `exportCsv(filename?)` | `(filename?: string) => Promise` | Starts an export. Uses `defaultFilename` when no name is given. | -| `cancel()` | `() => void` | Aborts the in-flight fetcher request. | -| `phase` | `"idle" \| "fetching" \| "serializing" \| "downloading" \| "success" \| "error" \| "cancelled"` | Current export phase. | -| `progress` | `{ completed: number; total: number \| null }` | Fetched row count and optional total. | -| `error` | `Error \| null` | Last export error. | +| Property | Type | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `defaultFilename` | `string` | Suggested download name. | +| `exportCsv(filename?)` | `(filename?: string) => Promise` | Starts an export. Resolves `true` after completion and uses `defaultFilename` when no name is given. | +| `cancel()` | `() => void` | Aborts the in-flight fetcher request. | +| `phase` | `"idle" \| "fetching" \| "serializing" \| "downloading" \| "success" \| "error" \| "cancelled"` | Current export phase. | +| `progress` | `{ completed: number; total: number \| null }` | Fetched row count and optional total. | +| `error` | `Error \| null` | Last export error. | CSV cells are escaped using RFC 4180-compatible quoting, UTF-8 BOM is included for spreadsheet compatibility, and formula-like cell values are escaped before download. diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts index f2efaf630..45e44efad 100644 --- a/packages/core/src/components/csv-exporter/types.ts +++ b/packages/core/src/components/csv-exporter/types.ts @@ -62,7 +62,8 @@ export interface UseCsvExporterOptions> { export interface CsvExporter { defaultFilename: string; - exportCsv: (filename?: string) => Promise; + /** Resolves true when the export completed (including an empty result). */ + exportCsv: (filename?: string) => Promise; cancel: () => void; phase: CsvExportPhase; progress: { diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx index 9cabe06fe..3c3fcca11 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -110,7 +110,7 @@ describe("useCsvExporter", () => { { wrapper }, ); - let exportPromise!: Promise; + let exportPromise!: Promise; act(() => { exportPromise = result.current.exportCsv(); }); diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index 8f418046d..ae735042a 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -101,7 +101,7 @@ export function useCsvExporter>({ const exportCsv = useCallback( async (requestedFilename = defaultFilename) => { - if (abortControllerRef.current) return; + if (abortControllerRef.current) return false; const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -141,7 +141,7 @@ export function useCsvExporter>({ if (rows.length === 0) { setPhase("success"); toast.info(t("noRows")); - return; + return true; } setPhase("serializing"); @@ -158,6 +158,7 @@ export function useCsvExporter>({ downloadCsv(csv, filename); setPhase("success"); toast.success(t("exportComplete", { count: rows.length })); + return true; } catch (caught) { if ( abortController.signal.aborted || @@ -171,6 +172,7 @@ export function useCsvExporter>({ setPhase("error"); toast.error(t("exportFailed")); } + return false; } finally { abortControllerRef.current = null; } diff --git a/packages/core/src/components/data-table/csv-export.test.tsx b/packages/core/src/components/data-table/csv-export.test.tsx index 463ee4405..c1995a397 100644 --- a/packages/core/src/components/data-table/csv-export.test.tsx +++ b/packages/core/src/components/data-table/csv-export.test.tsx @@ -15,7 +15,7 @@ afterEach(() => { describe("DataTable.CSVExport", () => { const idleExporter: CsvExporter = { defaultFilename: "products.csv", - exportCsv: vi.fn().mockResolvedValue(undefined), + exportCsv: vi.fn().mockResolvedValue(true), cancel: vi.fn(), phase: "idle", progress: { completed: 0, total: null }, @@ -57,7 +57,7 @@ describe("DataTable.CSVExport", () => { const user = userEvent.setup(); const exporter: CsvExporter = { ...idleExporter, - exportCsv: vi.fn().mockResolvedValue(undefined), + exportCsv: vi.fn().mockResolvedValue(true), }; render(, { wrapper }); diff --git a/packages/core/src/components/data-table/csv-export.tsx b/packages/core/src/components/data-table/csv-export.tsx index 834f38330..8413d61ed 100644 --- a/packages/core/src/components/data-table/csv-export.tsx +++ b/packages/core/src/components/data-table/csv-export.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; import { Download } from "lucide-react"; import { Button } from "@/components/button"; import { Dialog } from "@/components/dialog"; @@ -29,10 +29,6 @@ export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { const progress = total != null && total > 0 ? Math.min(100, (exporter.progress.completed / total) * 100) : null; - useEffect(() => { - if (exporter.phase === "success") setOpen(false); - }, [exporter.phase]); - const onOpenChange = (nextOpen: boolean) => { if (nextOpen && !isExporting) { setFilename(exporter.defaultFilename); @@ -41,13 +37,13 @@ export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { setOpen(nextOpen); }; - const startExport = () => { + const startExport = async () => { if (!filename.trim()) { setFilenameError(t("invalidFilename")); return; } setFilenameError(null); - void exporter.exportCsv(filename); + if (await exporter.exportCsv(filename)) setOpen(false); }; return ( @@ -82,7 +78,7 @@ export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { className="astw:flex astw:flex-col astw:gap-2" onSubmit={(event) => { event.preventDefault(); - startExport(); + void startExport(); }} >
) : ( -
+ id={formId} className="astw:flex astw:flex-col astw:gap-2" - onSubmit={(event) => { - event.preventDefault(); - void startExport(); - }} + noValidate + onFormSubmit={startExport} > - - { - setFilename(event.target.value); - setFilenameError(null); - }} - aria-invalid={filenameError != null} - aria-describedby={filenameError ? `${filenameId}-error` : undefined} - /> - {filenameError && ( - - )} + + typeof value === "string" && value.trim() ? undefined : t("invalidFilename") + } + > + {t("filename")} + setFilename(event.target.value)} + /> + + {exporter.error && (

{exporter.error.message}

)} -
+ )} @@ -119,7 +104,7 @@ export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { ) : ( <> }>{t("cancel")} - From a5bff6ccb8916277f9548c0f86abe9cc00e8928a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 21:32:48 +0900 Subject: [PATCH 05/14] refactor(data-table): compose csv toolbar placement --- docs/components/data-table.md | 8 ++++--- .../pages/showcase/data-table-lab/page.tsx | 8 ++++--- .../components/data-table/csv-export.test.tsx | 16 ------------- .../src/components/data-table/data-table.tsx | 3 +-- .../components/data-table/field-helpers.ts | 20 ++++++++++++++-- .../src/components/data-table/toolbar.tsx | 23 ++++--------------- 6 files changed, 33 insertions(+), 45 deletions(-) diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 9853d7b27..b09ad5cc4 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -202,12 +202,14 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi | ---------- | ------------- | --------------------------------------------------------------------------- | | `exporter` | `CsvExporter` | Return value from [`useCsvExporter`](../api/use-csv-exporter.md). Required. | -`DataTable.CSVExport` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. Place it as a direct `DataTable.Toolbar` child to render it immediately before the built-in Columns control: +`DataTable.CSVExport` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: ```tsx - - +
+ + +
``` diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index 6e0d27881..1b46c0517 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx @@ -521,7 +521,7 @@ const DataTableLabPage = () => { const exporter = useCsvExporter({ defaultFilename: "invoices.csv", - columns: settingsTable.columns, + columns: baseColumns, fetcher: async () => ({ edges: rows.map((node) => ({ node })), pageInfo: { hasNextPage: false, endCursor: null }, @@ -571,8 +571,10 @@ const DataTableLabPage = () => { > - - +
+ + +
diff --git a/packages/core/src/components/data-table/csv-export.test.tsx b/packages/core/src/components/data-table/csv-export.test.tsx index c1995a397..279360871 100644 --- a/packages/core/src/components/data-table/csv-export.test.tsx +++ b/packages/core/src/components/data-table/csv-export.test.tsx @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createAppShellWrapper } from "../../../tests/test-utils"; -import { DataTable } from "./data-table"; import { DataTableCSVExport } from "./csv-export"; import type { CsvExporter } from "@/components/csv-exporter"; @@ -22,21 +21,6 @@ describe("DataTable.CSVExport", () => { error: null, }; - it("moves a direct toolbar child beside column controls", () => { - const { container } = render( - - Filters - - , - { wrapper }, - ); - - const toolbar = container.querySelector('[data-slot="data-table-toolbar"]')!; - expect(toolbar.children).toHaveLength(2); - expect(toolbar.children[0]?.textContent).toContain("Filters"); - expect(toolbar.children[1]?.textContent).toContain("CSV Export"); - }); - it("can reopen an in-progress export dialog", async () => { const user = userEvent.setup(); const exporter: CsvExporter = { ...idleExporter, phase: "fetching" }; diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 20dcbe30b..b58c6583f 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1881,8 +1881,7 @@ export const DataTable = { /** * Container for toolbar content (filters, search, etc.). Place inside * `DataTable.Root`, before `DataTable.Table`. Pass `columnSettings` to render - * the built-in Columns control at the top-right. A direct `CSVExport` child - * is rendered immediately before it. + * the built-in "Columns" control (show/hide, reorder, pin) at the top-right. */ Toolbar: DataTableToolbar, /** diff --git a/packages/core/src/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts index 602dfb90e..569b19511 100644 --- a/packages/core/src/components/data-table/field-helpers.ts +++ b/packages/core/src/components/data-table/field-helpers.ts @@ -12,6 +12,19 @@ import type { Column, ColumnBase, MetadataFieldOptions } from "./types"; // column() helper // ============================================================================= +type ColumnWithCsvMetadata< + TRow extends Record, + TColumn extends Column, +> = Column & + (TColumn extends { label: infer TLabel extends string } + ? { label: TLabel } + : { label?: undefined }) & + (TColumn extends { id: infer TId extends string } + ? { id: TId } + : TColumn extends { accessor: infer TAccessor } + ? { accessor: TAccessor } + : {}); + /** * Define a column with explicit render and optional sort/filter/accessor. * Prefer {@link createColumnHelper} to bind `TRow` once at the helper level. @@ -146,7 +159,9 @@ export interface ColumnHelper> { * column({ label: "Actions", render: (row) => }) * ``` */ - column: >(options: TColumn) => TColumn; + column: >( + options: TColumn, + ) => ColumnWithCsvMetadata; /** * Bind table metadata once and return a per-field column factory. * @@ -193,7 +208,8 @@ export interface ColumnHelper> { */ export function createColumnHelper>(): ColumnHelper { return { - column: >(options: TColumn) => ({ ...options }), + column: >(options: TColumn) => + ({ ...options }) as ColumnWithCsvMetadata, inferColumns: (tableMetadata: TTable) => inferColumns(tableMetadata), }; diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index d22a870aa..d83455e98 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -1,4 +1,4 @@ -import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; import { Popover } from "@base-ui/react/popover"; import { ChevronDown, Filter as FilterIcon, X, Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -13,7 +13,6 @@ import { Tooltip } from "@/components/tooltip"; import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; import { DataTableColumnSettings } from "./column-settings"; -import { DataTableCSVExport } from "./csv-export"; import { useDataTableContext } from "./data-table-context"; import { isTemporalFilterType, isTemporalFilterValueValid } from "./filter-value-utils"; import { useDataTableT } from "./i18n"; @@ -46,14 +45,6 @@ function DataTableToolbar({ columnSettings?: boolean; className?: string; }) { - const toolbarChildren = Children.toArray(children); - const csvExports = toolbarChildren.filter( - (child) => isValidElement(child) && child.type === DataTableCSVExport, - ); - const content = toolbarChildren.filter( - (child) => !isValidElement(child) || child.type !== DataTableCSVExport, - ); - return (
{/* Left group keeps children in their original vertical stack (so existing - toolbars are visually unchanged). Direct CSV exports are controls, not - content, so they sit beside the fixed column-settings control. */} -
{content}
- {(csvExports.length > 0 || columnSettings) && ( -
- {csvExports} - {columnSettings && } -
- )} + toolbars are visually unchanged); the settings button sits top-right. */} +
{children}
+ {columnSettings && }
); } From c558c107c02240f0e6c0ffc2a21babed2aa85078 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 22:00:31 +0900 Subject: [PATCH 06/14] refactor(data-table): reconcile csv exporter columns --- .changeset/bright-csv-exporter.md | 4 +- docs/api/use-csv-exporter.md | 6 +-- docs/components/data-table.md | 24 ++++----- .../pages/showcase/data-table-lab/page.tsx | 2 +- .../src/pages/showcase/data-table/page.tsx | 2 +- .../core/src/components/csv-exporter/i18n.ts | 4 +- .../core/src/components/csv-exporter/types.ts | 8 ++- .../csv-exporter/use-csv-exporter.ts | 21 +++++--- ...-export.test.tsx => csv-exporter.test.tsx} | 50 +++++++++++++++---- .../{csv-export.tsx => csv-exporter.tsx} | 17 ++++--- .../src/components/data-table/data-table.tsx | 4 +- .../core/src/components/data-table/index.ts | 2 +- packages/core/src/index.ts | 2 +- 13 files changed, 95 insertions(+), 51 deletions(-) rename packages/core/src/components/data-table/{csv-export.test.tsx => csv-exporter.test.tsx} (51%) rename packages/core/src/components/data-table/{csv-export.tsx => csv-exporter.tsx} (88%) diff --git a/.changeset/bright-csv-exporter.md b/.changeset/bright-csv-exporter.md index bc37323f1..4afddf97a 100644 --- a/.changeset/bright-csv-exporter.md +++ b/.changeset/bright-csv-exporter.md @@ -6,7 +6,7 @@ Add `useCsvExporter` for client-side, cursor-paginated CSV downloads with progre ```tsx const exporter = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); -; +; ``` -`DataTable.CSVExport` provides a filename dialog and export progress UI. +`DataTable.CSVExporter` provides a filename dialog and export progress UI. diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md index 5de5def66..372a7a677 100644 --- a/docs/api/use-csv-exporter.md +++ b/docs/api/use-csv-exporter.md @@ -5,7 +5,7 @@ description: Fetch all cursor-paginated rows and download them as a CSV file wit # useCsvExporter -`useCsvExporter` fetches every page from a Relay-style cursor connection, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExport`](../components/data-table.md#csv-export). It is client-agnostic: the calling application implements the `fetcher` with its own data client. +`useCsvExporter` fetches every page from a Relay-style cursor connection, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. ## Import @@ -40,7 +40,7 @@ const exporter = useCsvExporter({ }, }); -; +; ``` The fetcher receives the cursor parameters for one page. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. @@ -86,7 +86,7 @@ const exporter = useCsvExporter({ }); ``` -A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table. +A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. When the exporter is passed to `DataTable.CSVExporter`, the current visible DataTable columns and their user-defined order are used. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table or must ignore user column settings. ## Return value diff --git a/docs/components/data-table.md b/docs/components/data-table.md index b09ad5cc4..e0e9f40ab 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -145,15 +145,15 @@ function JournalsPage() { `DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context. -| Sub-component | Description | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | -| `DataTable.Table` | Renders the `` with headers and body. Required. | -| `DataTable.Toolbar` | Container for toolbar content (e.g. filters). Optional. Pass `columnSettings` to render the built-in "Columns" control (show/hide + reorder + pin) at the top-right. See props below. | -| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | -| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | -| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | -| `DataTable.CSVExport` | Standard CSV download button and filename/progress dialog. Pass an exporter from [`useCsvExporter`](../api/use-csv-exporter.md) through `exporter`. | +| Sub-component | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | +| `DataTable.Table` | Renders the `
` with headers and body. Required. | +| `DataTable.Toolbar` | Container for toolbar content (e.g. filters). Optional. Pass `columnSettings` to render the built-in "Columns" control (show/hide + reorder + pin) at the top-right. See props below. | +| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | +| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | +| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | +| `DataTable.CSVExporter` | Standard CSV download button and filename/progress dialog. Pass an exporter from [`useCsvExporter`](../api/use-csv-exporter.md) through `exporter`. | ### `DataTable.Root` Props @@ -196,19 +196,19 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi ``` -### `DataTable.CSVExport` Props +### `DataTable.CSVExporter` Props | Prop | Type | Description | | ---------- | ------------- | --------------------------------------------------------------------------- | | `exporter` | `CsvExporter` | Return value from [`useCsvExporter`](../api/use-csv-exporter.md). Required. | -`DataTable.CSVExport` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: +`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: ```tsx
- +
``` diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index 1b46c0517..c4cae426e 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx @@ -573,7 +573,7 @@ const DataTableLabPage = () => {
- +
diff --git a/examples/vite-app/src/pages/showcase/data-table/page.tsx b/examples/vite-app/src/pages/showcase/data-table/page.tsx index aef651f42..04c378fc6 100644 --- a/examples/vite-app/src/pages/showcase/data-table/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table/page.tsx @@ -368,7 +368,7 @@ function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => Re {toolbar(control)} - + diff --git a/packages/core/src/components/csv-exporter/i18n.ts b/packages/core/src/components/csv-exporter/i18n.ts index 08332c868..0ca5bc460 100644 --- a/packages/core/src/components/csv-exporter/i18n.ts +++ b/packages/core/src/components/csv-exporter/i18n.ts @@ -9,7 +9,7 @@ export const csvExporterLabels = defineI18nLabels({ dialogTitle: "Download CSV", filename: "File name", download: "Download", - csvExport: "CSV Export", + csvExporter: "CSV Export", exporting: "Exporting CSV", fetchingRows: (props: { completed: number; total: number | null }) => props.total == null @@ -29,7 +29,7 @@ export const csvExporterLabels = defineI18nLabels({ dialogTitle: "CSV をダウンロード", filename: "ファイル名", download: "ダウンロード", - csvExport: "CSVエクスポート", + csvExporter: "CSVエクスポート", exporting: "CSV をエクスポート中", fetchingRows: (props: { completed: number; total: number | null }) => props.total == null diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts index 45e44efad..9de1ca32d 100644 --- a/packages/core/src/components/csv-exporter/types.ts +++ b/packages/core/src/components/csv-exporter/types.ts @@ -40,6 +40,10 @@ export type CsvExportColumnSource> = | readonly CsvExportColumn[] | readonly CsvExportableDataTableColumn[]; +type CsvExportColumnOverride> = + | readonly CsvExportColumn[] + | readonly Column[]; + export type CsvExportPhase = | "idle" | "fetching" @@ -60,10 +64,10 @@ export interface UseCsvExporterOptions> { pageSize?: number; } -export interface CsvExporter { +export interface CsvExporter = Record> { defaultFilename: string; /** Resolves true when the export completed (including an empty result). */ - exportCsv: (filename?: string) => Promise; + exportCsv: (filename?: string, columns?: CsvExportColumnOverride) => Promise; cancel: () => void; phase: CsvExportPhase; progress: { diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index ae735042a..c84a0ee3c 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { unparse } from "papaparse"; import { getCellValue } from "@/components/data-table/cell-renderers"; import type { Column } from "@/components/data-table/types"; @@ -8,7 +8,6 @@ import type { CsvCursorConnection, CsvExportCell, CsvExportColumn, - CsvExportColumnSource, CsvExporter, UseCsvExporterOptions, } from "./types"; @@ -41,7 +40,7 @@ function isCsvExportColumn>( } function resolveColumns>( - columns: CsvExportColumnSource, + columns: readonly CsvExportColumn[] | readonly Column[], ): CsvExportColumn[] { if (columns.length === 0 || isCsvExportColumn(columns[0])) { return columns as CsvExportColumn[]; @@ -78,7 +77,7 @@ function downloadCsv(csv: string, filename: string) { /** * Fetch every cursor page, serialize its rows as CSV, and expose progress for - * a UI such as `DataTable.CSVExport`. The data client stays outside this hook: + * a UI such as `DataTable.CSVExporter`. The data client stays outside this hook: * `fetcher` only needs to return a Relay-style cursor connection. */ export function useCsvExporter>({ @@ -86,10 +85,10 @@ export function useCsvExporter>({ columns: columnSource, fetcher, pageSize = DEFAULT_PAGE_SIZE, -}: UseCsvExporterOptions): CsvExporter { +}: UseCsvExporterOptions): CsvExporter { const toast = useToast(); const t = useCsvExporterT(); - const columns = useMemo(() => resolveColumns(columnSource), [columnSource]); + const usesDataTableColumns = columnSource.length > 0 && !isCsvExportColumn(columnSource[0]); const abortControllerRef = useRef(null); const [phase, setPhase] = useState("idle"); const [progress, setProgress] = useState({ completed: 0, total: null }); @@ -100,7 +99,10 @@ export function useCsvExporter>({ }, []); const exportCsv = useCallback( - async (requestedFilename = defaultFilename) => { + async ( + requestedFilename = defaultFilename, + columnOverride?: readonly CsvExportColumn[] | readonly Column[], + ) => { if (abortControllerRef.current) return false; const abortController = new AbortController(); @@ -111,6 +113,9 @@ export function useCsvExporter>({ try { const filename = normalizeFilename(requestedFilename); + const columns = resolveColumns( + columnOverride && usesDataTableColumns ? columnOverride : columnSource, + ); const rows: TRow[] = []; const seenCursors = new Set(); let after: string | null = null; @@ -177,7 +182,7 @@ export function useCsvExporter>({ abortControllerRef.current = null; } }, - [columns, defaultFilename, fetcher, pageSize, t, toast], + [columnSource, defaultFilename, fetcher, pageSize, t, toast, usesDataTableColumns], ); return { defaultFilename, exportCsv, cancel, phase, progress, error }; diff --git a/packages/core/src/components/data-table/csv-export.test.tsx b/packages/core/src/components/data-table/csv-exporter.test.tsx similarity index 51% rename from packages/core/src/components/data-table/csv-export.test.tsx rename to packages/core/src/components/data-table/csv-exporter.test.tsx index 279360871..26a1f50cf 100644 --- a/packages/core/src/components/data-table/csv-export.test.tsx +++ b/packages/core/src/components/data-table/csv-exporter.test.tsx @@ -1,18 +1,43 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createAppShellWrapper } from "../../../tests/test-utils"; -import { DataTableCSVExport } from "./csv-export"; +import { useEffect } from "react"; +import { DataTable } from "./data-table"; +import { DataTableCSVExporter } from "./csv-exporter"; +import { useDataTable } from "./use-data-table"; import type { CsvExporter } from "@/components/csv-exporter"; const wrapper = createAppShellWrapper("en"); +type Row = { name: string }; + +function TestCSVExporter({ exporter }: { exporter: CsvExporter }) { + const table = useDataTable({ + columns: [ + { id: "name", label: "Name", render: (row) => row.name }, + { id: "hidden", label: "Hidden", render: () => "hidden" }, + ], + data: { rows: [] }, + }); + + const { toggleColumn } = table; + useEffect(() => { + toggleColumn("hidden"); + }, [toggleColumn]); + + return ( + + + + ); +} afterEach(() => { cleanup(); }); -describe("DataTable.CSVExport", () => { - const idleExporter: CsvExporter = { +describe("DataTable.CSVExporter", () => { + const idleExporter: CsvExporter = { defaultFilename: "products.csv", exportCsv: vi.fn().mockResolvedValue(true), cancel: vi.fn(), @@ -23,9 +48,9 @@ describe("DataTable.CSVExport", () => { it("can reopen an in-progress export dialog", async () => { const user = userEvent.setup(); - const exporter: CsvExporter = { ...idleExporter, phase: "fetching" }; + const exporter: CsvExporter = { ...idleExporter, phase: "fetching" }; - render(, { wrapper }); + render(, { wrapper }); await user.click(screen.getByRole("button", { name: "CSV Export" })); expect(screen.getByRole("dialog")).toBeDefined(); @@ -37,14 +62,14 @@ describe("DataTable.CSVExport", () => { expect(screen.getByRole("dialog")).toBeDefined(); }); - it("uses the dialog filename when starting an export", async () => { + it("uses the dialog filename and visible columns when starting an export", async () => { const user = userEvent.setup(); - const exporter: CsvExporter = { + const exporter: CsvExporter = { ...idleExporter, exportCsv: vi.fn().mockResolvedValue(true), }; - render(, { wrapper }); + render(, { wrapper }); await user.click(screen.getByRole("button", { name: "CSV Export" })); const filename = screen.getByLabelText("File name"); @@ -52,6 +77,11 @@ describe("DataTable.CSVExport", () => { await user.type(filename, "all-products"); await user.click(screen.getByRole("button", { name: "Download" })); - expect(exporter.exportCsv).toHaveBeenCalledWith("all-products"); + await waitFor(() => { + expect(exporter.exportCsv).toHaveBeenCalled(); + }); + expect(exporter.exportCsv).toHaveBeenCalledWith("all-products", [ + expect.objectContaining({ id: "name" }), + ]); }); }); diff --git a/packages/core/src/components/data-table/csv-export.tsx b/packages/core/src/components/data-table/csv-exporter.tsx similarity index 88% rename from packages/core/src/components/data-table/csv-export.tsx rename to packages/core/src/components/data-table/csv-exporter.tsx index 2542c967b..7032f8d57 100644 --- a/packages/core/src/components/data-table/csv-export.tsx +++ b/packages/core/src/components/data-table/csv-exporter.tsx @@ -7,16 +7,21 @@ import { Form } from "@/components/form"; import { Spinner } from "@/components/spinner"; import { useCsvExporterT } from "@/components/csv-exporter/i18n"; import type { CsvExporter } from "@/components/csv-exporter"; +import { useDataTableContext } from "./data-table-context"; -export interface DataTableCSVExportProps { - exporter: CsvExporter; +export interface DataTableCSVExporterProps> { + exporter: CsvExporter; } /** * Standard CSV export button and progress dialog. Supply the stateful value - * returned by `useCsvExporter` through `exporter`. + * returned by `useCsvExporter` through `exporter`. Visible DataTable columns + * are used in their current order when the exporter was configured from columns. */ -export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { +export function DataTableCSVExporter>({ + exporter, +}: DataTableCSVExporterProps) { + const { visibleColumns } = useDataTableContext(); const t = useCsvExporterT(); const formId = useId(); const [open, setOpen] = useState(false); @@ -37,7 +42,7 @@ export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) { }; const startExport = async ({ filename: exportFilename }: { filename: string }) => { - if (await exporter.exportCsv(exportFilename)) setOpen(false); + if (await exporter.exportCsv(exportFilename, visibleColumns)) setOpen(false); }; return ( @@ -45,7 +50,7 @@ export function DataTableCSVExport({ exporter }: DataTableCSVExportProps) {
}> - {t("csvExport")} + {t("csvExporter")}
diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index b58c6583f..fb9c3071c 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -43,7 +43,7 @@ import { getVisibleFilterOperators, } from "./toolbar"; import { DataTablePagination } from "./pagination"; -import { DataTableCSVExport } from "./csv-export"; +import { DataTableCSVExporter } from "./csv-exporter"; export type { DataTablePaginationProps } from "./pagination"; // Fallback row count when no pageSize is configured (static / uncontrolled tables) @@ -1916,5 +1916,5 @@ export const DataTable = { * Standard CSV export button and progress dialog. Pass the result of * `useCsvExporter()` through its `exporter` prop. */ - CSVExport: DataTableCSVExport, + CSVExporter: DataTableCSVExporter, } as const; diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index a28269464..eb0c0c91b 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -1,6 +1,6 @@ // DataTable compound component export { DataTable, type DataTablePaginationProps, type DataTableRootProps } from "./data-table"; -export { type DataTableCSVExportProps } from "./csv-export"; +export { type DataTableCSVExporterProps } from "./csv-exporter"; export { useDataTable } from "./use-data-table"; export { useDataTableContext, type DataTableContextValue } from "./data-table-context"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 790949edf..07b27c3db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -315,7 +315,7 @@ export { type UseDataTableReturn, type MetadataFieldOptions, type DataTableContextValue, - type DataTableCSVExportProps, + type DataTableCSVExporterProps, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; export { useURLCollectionVariables, withURLCollectionState } from "./lib/collection-url-state"; From 828c0dffa41a6bdd8e44039d02361c58b50b296b Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 22:36:26 +0900 Subject: [PATCH 07/14] refactor(csv-exporter): add standalone component --- docs/api/use-csv-exporter.md | 17 +- docs/components/csv-exporter.md | 52 ++++++ docs/components/data-table.md | 10 +- .../pages/showcase/data-table-lab/page.tsx | 4 +- .../src/pages/showcase/data-table/page.tsx | 4 +- .../components/csv-exporter/csv-exporter.tsx | 161 ++++++++++++++++++ .../core/src/components/csv-exporter/index.ts | 4 +- .../core/src/components/csv-exporter/types.ts | 59 ++++++- .../csv-exporter/use-csv-exporter.test.tsx | 30 +++- .../csv-exporter/use-csv-exporter.ts | 52 +++++- .../data-table/csv-exporter.test.tsx | 15 +- .../components/data-table/csv-exporter.tsx | 158 +++-------------- .../src/components/data-table/data-table.tsx | 11 +- packages/core/src/index.ts | 4 +- 14 files changed, 400 insertions(+), 181 deletions(-) create mode 100644 docs/components/csv-exporter.md create mode 100644 packages/core/src/components/csv-exporter/csv-exporter.tsx diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md index 372a7a677..ad62e2203 100644 --- a/docs/api/use-csv-exporter.md +++ b/docs/api/use-csv-exporter.md @@ -11,6 +11,7 @@ description: Fetch all cursor-paginated rows and download them as a CSV file wit ```tsx import { + CsvExporter, useCsvExporter, type CsvCursorConnection, type CsvExportColumn, @@ -20,7 +21,7 @@ import { ## Usage ```tsx -const exporter = useCsvExporter({ +const { props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher: async ({ first, after, signal }) => { @@ -40,7 +41,7 @@ const exporter = useCsvExporter({ }, }); -; +; ``` The fetcher receives the cursor parameters for one page. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. @@ -90,13 +91,9 @@ A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScrip ## Return value -| Property | Type | Description | -| ---------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `defaultFilename` | `string` | Suggested download name. | -| `exportCsv(filename?)` | `(filename?: string) => Promise` | Starts an export. Resolves `true` after completion and uses `defaultFilename` when no name is given. | -| `cancel()` | `() => void` | Aborts the in-flight fetcher request. | -| `phase` | `"idle" \| "fetching" \| "serializing" \| "downloading" \| "success" \| "error" \| "cancelled"` | Current export phase. | -| `progress` | `{ completed: number; total: number \| null }` | Fetched row count and optional total. | -| `error` | `Error \| null` | Last export error. | +| Property | Type | Description | +| -------- | ------------------------ | -------------------------------------------------------------------------------------------- | +| `open()` | `() => void` | Opens the standard exporter UI. | +| `props` | `CsvExporterProps` | Spread directly onto `` or ``. | CSV cells are escaped using RFC 4180-compatible quoting, UTF-8 BOM is included for spreadsheet compatibility, and formula-like cell values are escaped before download. diff --git a/docs/components/csv-exporter.md b/docs/components/csv-exporter.md new file mode 100644 index 000000000..4f018bc0b --- /dev/null +++ b/docs/components/csv-exporter.md @@ -0,0 +1,52 @@ +--- +title: CsvExporter +description: Filename dialog and progress UI for client-side cursor-paginated CSV downloads +--- + +# CsvExporter + +`CsvExporter` is the standalone UI for [`useCsvExporter`](../api/use-csv-exporter.md). It provides a trigger button, editable filename dialog, fetch progress, and cancellation without requiring a DataTable. + +## Import + +```tsx +import { CsvExporter, useCsvExporter } from "@tailor-platform/app-shell"; +``` + +## Usage + +```tsx +const { open, props } = useCsvExporter({ + defaultFilename: "products.csv", + columns: [ + { header: "Product code", value: (product) => product.code }, + { header: "Name", value: (product) => product.name }, + ], + fetcher, +}); + + +; +``` + +`useCsvExporter` owns the state. Its `open()` function opens the dialog, while `props` contains the controlled `open`, `onOpenChange`, and `exporter` values consumed by this component. + +## Props + +| Prop | Type | Description | +| -------------- | ------------------------- | ------------------------------------------------------ | +| `open` | `boolean` | Whether the exporter dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | +| `exporter` | `CsvExporterState` | Export state and actions returned by `useCsvExporter`. | + +The filename input is validated through AppShell `Form` and `Field`. While fetching, the dialog shows fetched rows and a determinate progress bar when the connection returns `total`. The user can cancel the request, or dismiss and reopen the dialog while the export continues. + +## DataTable + +For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props: + +```tsx + +``` + +When the hook is configured with DataTable columns, this variant exports only visible columns in their current user-defined order. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index e0e9f40ab..de7c24be1 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -198,9 +198,11 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi ### `DataTable.CSVExporter` Props -| Prop | Type | Description | -| ---------- | ------------- | --------------------------------------------------------------------------- | -| `exporter` | `CsvExporter` | Return value from [`useCsvExporter`](../api/use-csv-exporter.md). Required. | +| Prop | Type | Description | +| -------------- | ------------------------- | ----------------------------------------------------------------------------- | +| `open` | `boolean` | Whether the exporter dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | +| `exporter` | `CsvExporterState` | Export state and actions from [`useCsvExporter`](../api/use-csv-exporter.md). | `DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: @@ -208,7 +210,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi
- +
``` diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index c4cae426e..bfb74a116 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx @@ -519,7 +519,7 @@ const DataTableLabPage = () => { rowActions, }); - const exporter = useCsvExporter({ + const { props: exporter } = useCsvExporter({ defaultFilename: "invoices.csv", columns: baseColumns, fetcher: async () => ({ @@ -573,7 +573,7 @@ const DataTableLabPage = () => {
- +
diff --git a/examples/vite-app/src/pages/showcase/data-table/page.tsx b/examples/vite-app/src/pages/showcase/data-table/page.tsx index 04c378fc6..b159ff949 100644 --- a/examples/vite-app/src/pages/showcase/data-table/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table/page.tsx @@ -343,7 +343,7 @@ function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => Re }, [variables, variablesKey]); const table = useDataTable({ columns, data, loading, control }); - const exporter = useCsvExporter({ + const { props: exporter } = useCsvExporter({ defaultFilename: "invoices.csv", columns, pageSize: 10, @@ -368,7 +368,7 @@ function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => Re {toolbar(control)} - + diff --git a/packages/core/src/components/csv-exporter/csv-exporter.tsx b/packages/core/src/components/csv-exporter/csv-exporter.tsx new file mode 100644 index 000000000..7d5a69c7d --- /dev/null +++ b/packages/core/src/components/csv-exporter/csv-exporter.tsx @@ -0,0 +1,161 @@ +import { useId, useState } from "react"; +import { Download } from "lucide-react"; +import { Button } from "@/components/button"; +import { Dialog } from "@/components/dialog"; +import { Field } from "@/components/field"; +import { Form } from "@/components/form"; +import { Spinner } from "@/components/spinner"; +import { useCsvExporterT } from "@/components/csv-exporter/i18n"; +import type { Column } from "@/components/data-table/types"; +import type { CsvExporterProps } from "./types"; + +type CsvExporterViewProps> = CsvExporterProps & { + columnOverride?: readonly Column[]; +}; + +/** @internal Shared CSV export UI. DataTable supplies its visible columns here. */ +export function CsvExporterView>({ + open, + onOpenChange, + exporter, + columnOverride, +}: CsvExporterViewProps) { + const t = useCsvExporterT(); + const formId = useId(); + const [filename, setFilename] = useState(exporter.defaultFilename); + const isExporting = + exporter.phase === "fetching" || + exporter.phase === "serializing" || + exporter.phase === "downloading"; + const total = exporter.progress.total; + const progress = + total != null && total > 0 ? Math.min(100, (exporter.progress.completed / total) * 100) : null; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setFilename(exporter.defaultFilename); + onOpenChange(nextOpen); + }; + + const startExport = async ({ filename: exportFilename }: { filename: string }) => { + if (await exporter.exportCsv(exportFilename, columnOverride)) onOpenChange(false); + }; + + return ( + +
+ }> + + {t("csvExporter")} + +
+ + + {isExporting ? t("exporting") : t("dialogTitle")} + + + {isExporting ? ( +
+ {exporter.phase === "fetching" ? ( + <> +

{t("fetchingRows", exporter.progress)}

+ + + ) : ( +
+ +

{t("creatingFile")}

+
+ )} +
+ ) : ( + + id={formId} + className="astw:flex astw:flex-col astw:gap-2" + noValidate + onFormSubmit={startExport} + > + + typeof value === "string" && value.trim() ? undefined : t("invalidFilename") + } + > + {t("filename")} + setFilename(event.target.value)} + /> + + + {exporter.error && ( +

+ {exporter.error.message} +

+ )} + + )} + + + {isExporting ? ( + + ) : ( + <> + }>{t("cancel")} + + + )} + +
+
+ ); +} + +/** + * Standard CSV export button, filename dialog, and progress UI. + * + * @example + * ```tsx + * const { open, props } = useCsvExporter({ + * defaultFilename: "products.csv", + * columns, + * fetcher, + * }); + * + * + * ; + * ``` + * + * Use `DataTable.CSVExporter` instead when the export should reconcile the + * current DataTable column layout. + */ +export function CsvExporter>(props: CsvExporterProps) { + return ; +} + +function CsvExportProgress({ label, value }: { label: string; value: number | null }) { + if (value == null) { + return ( +
+
+
+ ); + } + + return ( + + ); +} diff --git a/packages/core/src/components/csv-exporter/index.ts b/packages/core/src/components/csv-exporter/index.ts index 2c3198fc2..3a95365f3 100644 --- a/packages/core/src/components/csv-exporter/index.ts +++ b/packages/core/src/components/csv-exporter/index.ts @@ -1,3 +1,4 @@ +export { CsvExporter } from "./csv-exporter"; export { useCsvExporter } from "./use-csv-exporter"; export type { CsvCursorConnection, @@ -5,6 +6,7 @@ export type { CsvExportCell, CsvExportColumn, CsvExportPhase, - CsvExporter, + CsvExporterState, + CsvExporterProps, UseCsvExporterOptions, } from "./types"; diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts index 9de1ca32d..7033efe1c 100644 --- a/packages/core/src/components/csv-exporter/types.ts +++ b/packages/core/src/components/csv-exporter/types.ts @@ -1,8 +1,22 @@ import type { Column } from "@/components/data-table/types"; +/** A scalar value supported by a CSV cell. Nullish values become empty cells. */ export type CsvExportCell = string | number | boolean | bigint | null | undefined; -/** A CSV column independent of any rendered table. */ +/** + * A CSV column independent of any rendered table. + * + * Use this form when the export is a report with its own headers or value + * formatting, rather than a copy of a DataTable's visible columns. + * + * @example + * ```tsx + * const columns: CsvExportColumn[] = [ + * { header: "Product code", value: (product) => product.code }, + * { header: "Created", value: (product) => product.createdAt.toISOString() }, + * ]; + * ``` + */ export interface CsvExportColumn { header: string; value: (row: TRow) => CsvExportCell; @@ -19,6 +33,12 @@ export interface CsvCursorConnection { total?: number | null; } +/** + * Fetches one page for a client-side CSV export. Throw client or API errors; + * `useCsvExporter` reports them and stops before downloading a partial file. + * + * Honor `signal` when the data client supports cancellation. + */ export type CsvCursorFetcher = (pagination: { first: number; after: string | null; @@ -44,6 +64,7 @@ type CsvExportColumnOverride> = | readonly CsvExportColumn[] | readonly Column[]; +/** Current stage of a CSV export. */ export type CsvExportPhase = | "idle" | "fetching" @@ -53,6 +74,12 @@ export type CsvExportPhase = | "error" | "cancelled"; +/** + * Configuration for `useCsvExporter`. + * + * Pass DataTable columns to make `DataTable.CSVExporter` honor the user's + * visible-column layout, or pass `CsvExportColumn[]` for a fixed report schema. + */ export interface UseCsvExporterOptions> { /** Initial filename displayed in the export dialog. The .csv suffix is added when omitted. */ defaultFilename: string; @@ -64,7 +91,22 @@ export interface UseCsvExporterOptions> { pageSize?: number; } -export interface CsvExporter = Record> { +/** + * State and actions returned inside `useCsvExporter`'s `props` value. + * + * `CsvExporter` and `DataTable.CSVExporter` receive this value through their + * `exporter` prop. Call `exportCsv()` from a + * custom button when no DataTable is involved. + * + * @example + * ```tsx + * const { open, props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); + * + * + * ; + * ``` + */ +export interface CsvExporterState = Record> { defaultFilename: string; /** Resolves true when the export completed (including an empty result). */ exportCsv: (filename?: string, columns?: CsvExportColumnOverride) => Promise; @@ -76,3 +118,16 @@ export interface CsvExporter = Record = Record> { + open: boolean; + onOpenChange: (open: boolean) => void; + exporter: CsvExporterState; +} diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx index 3c3fcca11..2b1cb1578 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -17,6 +17,24 @@ afterEach(() => { }); describe("useCsvExporter", () => { + it("returns controlled component props and an open action", () => { + const { result } = renderHook( + () => + useCsvExporter({ + defaultFilename: "products.csv", + columns: [{ header: "Name", value: () => "Alice" }], + fetcher: async () => null, + }), + { wrapper }, + ); + + expect(result.current.props.open).toBe(false); + act(() => result.current.open()); + expect(result.current.props.open).toBe(true); + act(() => result.current.props.onOpenChange(false)); + expect(result.current.props.open).toBe(false); + }); + it("requires a raw value source for labelled DataTable columns", () => { const { column } = createColumnHelper(); const valid: UseCsvExporterOptions = { @@ -73,7 +91,7 @@ describe("useCsvExporter", () => { { wrapper }, ); - await act(() => result.current.exportCsv()); + await act(() => result.current.props.exporter.exportCsv()); expect(fetcher).toHaveBeenNthCalledWith(1, { first: 1000, @@ -85,8 +103,8 @@ describe("useCsvExporter", () => { after: "cursor-1", signal: expect.any(AbortSignal), }); - expect(result.current.phase).toBe("success"); - expect(result.current.progress).toEqual({ completed: 2, total: 2 }); + expect(result.current.props.exporter.phase).toBe("success"); + expect(result.current.props.exporter.progress).toEqual({ completed: 2, total: 2 }); expect(await downloaded[0]!.text()).toBe( '\uFEFFName,Formula\r\nAlice,"\'=SUM(A1:A2)"\r\nBob,safe', @@ -112,15 +130,15 @@ describe("useCsvExporter", () => { let exportPromise!: Promise; act(() => { - exportPromise = result.current.exportCsv(); + exportPromise = result.current.props.exporter.exportCsv(); }); - act(() => result.current.cancel()); + act(() => result.current.props.exporter.cancel()); await act(async () => { await exportPromise; }); await waitFor(() => { - expect(result.current.phase).toBe("cancelled"); + expect(result.current.props.exporter.phase).toBe("cancelled"); }); }); }); diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index c84a0ee3c..e9fa479ed 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -8,7 +8,8 @@ import type { CsvCursorConnection, CsvExportCell, CsvExportColumn, - CsvExporter, + CsvExporterState, + CsvExporterProps, UseCsvExporterOptions, } from "./types"; @@ -76,22 +77,47 @@ function downloadCsv(csv: string, filename: string) { } /** - * Fetch every cursor page, serialize its rows as CSV, and expose progress for - * a UI such as `DataTable.CSVExporter`. The data client stays outside this hook: - * `fetcher` only needs to return a Relay-style cursor connection. + * Fetch every cursor page, serialize its rows as CSV, and expose progress and + * cancellation actions. The data client stays outside this hook: `fetcher` + * only needs to return a Relay-style cursor connection. + * + * @example + * ```tsx + * const { props } = useCsvExporter({ + * defaultFilename: "products.csv", + * columns, + * fetcher: async ({ first, after, signal }) => { + * const result = await client.listProducts({ first, after, signal }); + * return result.connection; + * }, + * }); + * + * ; + * ``` + * + * When `columns` is a DataTable column array, `DataTable.CSVExporter` exports + * its current visible columns in the user's selected order. Explicit + * `CsvExportColumn[]` definitions remain a fixed report schema instead. */ export function useCsvExporter>({ defaultFilename, columns: columnSource, fetcher, pageSize = DEFAULT_PAGE_SIZE, -}: UseCsvExporterOptions): CsvExporter { +}: UseCsvExporterOptions) { const toast = useToast(); const t = useCsvExporterT(); const usesDataTableColumns = columnSource.length > 0 && !isCsvExportColumn(columnSource[0]); + const [isOpen, setIsOpen] = useState(false); + const open = useCallback(() => { + setIsOpen(true); + }, []); const abortControllerRef = useRef(null); - const [phase, setPhase] = useState("idle"); - const [progress, setProgress] = useState({ completed: 0, total: null }); + const [phase, setPhase] = useState("idle"); + const [progress, setProgress] = useState({ + completed: 0, + total: null, + }); const [error, setError] = useState(null); const cancel = useCallback(() => { @@ -185,7 +211,17 @@ export function useCsvExporter>({ [columnSource, defaultFilename, fetcher, pageSize, t, toast, usesDataTableColumns], ); - return { defaultFilename, exportCsv, cancel, phase, progress, error }; + const exporter: CsvExporterState = { + defaultFilename, + exportCsv, + cancel, + phase, + progress, + error, + }; + const props: CsvExporterProps = { open: isOpen, onOpenChange: setIsOpen, exporter }; + + return { open, props }; } export type { CsvCursorConnection }; diff --git a/packages/core/src/components/data-table/csv-exporter.test.tsx b/packages/core/src/components/data-table/csv-exporter.test.tsx index 26a1f50cf..d5305262d 100644 --- a/packages/core/src/components/data-table/csv-exporter.test.tsx +++ b/packages/core/src/components/data-table/csv-exporter.test.tsx @@ -1,17 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useEffect, useState } from "react"; import { createAppShellWrapper } from "../../../tests/test-utils"; -import { useEffect } from "react"; import { DataTable } from "./data-table"; import { DataTableCSVExporter } from "./csv-exporter"; import { useDataTable } from "./use-data-table"; -import type { CsvExporter } from "@/components/csv-exporter"; +import type { CsvExporterState } from "@/components/csv-exporter"; const wrapper = createAppShellWrapper("en"); type Row = { name: string }; -function TestCSVExporter({ exporter }: { exporter: CsvExporter }) { +function TestCSVExporter({ exporter }: { exporter: CsvExporterState }) { + const [open, setOpen] = useState(false); const table = useDataTable({ columns: [ { id: "name", label: "Name", render: (row) => row.name }, @@ -27,7 +28,7 @@ function TestCSVExporter({ exporter }: { exporter: CsvExporter }) { return ( - + ); } @@ -37,7 +38,7 @@ afterEach(() => { }); describe("DataTable.CSVExporter", () => { - const idleExporter: CsvExporter = { + const idleExporter: CsvExporterState = { defaultFilename: "products.csv", exportCsv: vi.fn().mockResolvedValue(true), cancel: vi.fn(), @@ -48,7 +49,7 @@ describe("DataTable.CSVExporter", () => { it("can reopen an in-progress export dialog", async () => { const user = userEvent.setup(); - const exporter: CsvExporter = { ...idleExporter, phase: "fetching" }; + const exporter: CsvExporterState = { ...idleExporter, phase: "fetching" }; render(, { wrapper }); @@ -64,7 +65,7 @@ describe("DataTable.CSVExporter", () => { it("uses the dialog filename and visible columns when starting an export", async () => { const user = userEvent.setup(); - const exporter: CsvExporter = { + const exporter: CsvExporterState = { ...idleExporter, exportCsv: vi.fn().mockResolvedValue(true), }; diff --git a/packages/core/src/components/data-table/csv-exporter.tsx b/packages/core/src/components/data-table/csv-exporter.tsx index 7032f8d57..437b7ad49 100644 --- a/packages/core/src/components/data-table/csv-exporter.tsx +++ b/packages/core/src/components/data-table/csv-exporter.tsx @@ -1,144 +1,30 @@ -import { useId, useState } from "react"; -import { Download } from "lucide-react"; -import { Button } from "@/components/button"; -import { Dialog } from "@/components/dialog"; -import { Field } from "@/components/field"; -import { Form } from "@/components/form"; -import { Spinner } from "@/components/spinner"; -import { useCsvExporterT } from "@/components/csv-exporter/i18n"; -import type { CsvExporter } from "@/components/csv-exporter"; +import { CsvExporterView } from "@/components/csv-exporter/csv-exporter"; +import type { CsvExporterProps } from "@/components/csv-exporter"; import { useDataTableContext } from "./data-table-context"; -export interface DataTableCSVExporterProps> { - exporter: CsvExporter; -} - /** - * Standard CSV export button and progress dialog. Supply the stateful value - * returned by `useCsvExporter` through `exporter`. Visible DataTable columns - * are used in their current order when the exporter was configured from columns. + * CSV exporter integrated with the enclosing DataTable. + * + * Its UI and lifecycle are identical to `CsvExporter`; the only addition is + * that the table's current visible columns and order become the CSV schema when + * `useCsvExporter` was configured with DataTable columns. + * + * @example + * ```tsx + * const { props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); + * + * + * + * ; + * ``` */ -export function DataTableCSVExporter>({ - exporter, -}: DataTableCSVExporterProps) { +export function DataTableCSVExporter>( + props: CsvExporterProps, +) { const { visibleColumns } = useDataTableContext(); - const t = useCsvExporterT(); - const formId = useId(); - const [open, setOpen] = useState(false); - const [filename, setFilename] = useState(exporter.defaultFilename); - const isExporting = - exporter.phase === "fetching" || - exporter.phase === "serializing" || - exporter.phase === "downloading"; - const total = exporter.progress.total; - const progress = - total != null && total > 0 ? Math.min(100, (exporter.progress.completed / total) * 100) : null; - - const onOpenChange = (nextOpen: boolean) => { - if (nextOpen && !isExporting) { - setFilename(exporter.defaultFilename); - } - setOpen(nextOpen); - }; - - const startExport = async ({ filename: exportFilename }: { filename: string }) => { - if (await exporter.exportCsv(exportFilename, visibleColumns)) setOpen(false); - }; - return ( - -
- }> - - {t("csvExporter")} - -
- - - {isExporting ? t("exporting") : t("dialogTitle")} - - - {isExporting ? ( -
- {exporter.phase === "fetching" ? ( - <> -

{t("fetchingRows", exporter.progress)}

- - - ) : ( -
- -

{t("creatingFile")}

-
- )} -
- ) : ( - - id={formId} - className="astw:flex astw:flex-col astw:gap-2" - noValidate - onFormSubmit={startExport} - > - - typeof value === "string" && value.trim() ? undefined : t("invalidFilename") - } - > - {t("filename")} - setFilename(event.target.value)} - /> - - - {exporter.error && ( -

- {exporter.error.message} -

- )} - - )} - - - {isExporting ? ( - - ) : ( - <> - }>{t("cancel")} - - - )} - -
-
- ); + return ; } -function CsvExportProgress({ label, value }: { label: string; value: number | null }) { - if (value == null) { - return ( -
-
-
- ); - } - - return ( - - ); -} +export type DataTableCSVExporterProps> = + CsvExporterProps; diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index fb9c3071c..c66234132 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1913,8 +1913,15 @@ export const DataTable = { */ Pagination: DataTablePagination, /** - * Standard CSV export button and progress dialog. Pass the result of - * `useCsvExporter()` through its `exporter` prop. + * Standard CSV export button, filename dialog, and progress UI. + * + * Place inside `DataTable.Root`, usually in `DataTable.Toolbar`, and spread + * the `props` value returned from `useCsvExporter()`. + * + * @example + * ```tsx + * + * ``` */ CSVExporter: DataTableCSVExporter, } as const; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07b27c3db..0f21b944d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -256,13 +256,15 @@ export { type InferCsvRow, } from "./components/csv-importer"; export { + CsvExporter, useCsvExporter, type CsvCursorConnection, type CsvCursorFetcher, type CsvExportCell, type CsvExportColumn, type CsvExportPhase, - type CsvExporter, + type CsvExporterState, + type CsvExporterProps, type UseCsvExporterOptions, } from "./components/csv-exporter"; From 7d8ae2ae916f9c5e7bea2be1009dddf00750c708 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 22:44:44 +0900 Subject: [PATCH 08/14] docs(showcase): combine CSV feature demos --- .changeset/bright-csv-exporter.md | 14 +- examples/vite-app/src/App.tsx | 2 +- .../src/pages/showcase/csv-features/page.tsx | 166 ++++++++++++++++++ .../src/pages/showcase/csv-importer/page.tsx | 129 -------------- examples/vite-app/src/routes.generated.ts | 2 +- .../components/csv-exporter/csv-exporter.tsx | 7 - .../components/data-table/csv-exporter.tsx | 16 +- 7 files changed, 193 insertions(+), 143 deletions(-) create mode 100644 examples/vite-app/src/pages/showcase/csv-features/page.tsx delete mode 100644 examples/vite-app/src/pages/showcase/csv-importer/page.tsx diff --git a/.changeset/bright-csv-exporter.md b/.changeset/bright-csv-exporter.md index 4afddf97a..712d731bf 100644 --- a/.changeset/bright-csv-exporter.md +++ b/.changeset/bright-csv-exporter.md @@ -2,11 +2,17 @@ "@tailor-platform/app-shell": minor --- -Add `useCsvExporter` for client-side, cursor-paginated CSV downloads with progress and cancellation. +Add `useCsvExporter` and the standalone `CsvExporter` dialog for client-side, cursor-paginated CSV downloads with filename editing, progress, cancellation, UTF-8 BOM, and spreadsheet formula escaping. It is data-client and DataTable independent, so it can be opened from any button or collection UI. ```tsx -const exporter = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); -; +const { open, props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); + + +; ``` -`DataTable.CSVExporter` provides a filename dialog and export progress UI. +Add `DataTable.CSVExporter`, which accepts the same props and, when the hook receives DataTable columns, exports the user's current visible columns in their selected order. + +```tsx + +``` diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index c778b6a64..11bb8f636 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -65,7 +65,7 @@ const AppInner = () => { - + diff --git a/examples/vite-app/src/pages/showcase/csv-features/page.tsx b/examples/vite-app/src/pages/showcase/csv-features/page.tsx new file mode 100644 index 000000000..9b776113b --- /dev/null +++ b/examples/vite-app/src/pages/showcase/csv-features/page.tsx @@ -0,0 +1,166 @@ +import { + Layout, + Button, + CsvExporter, + CsvImporter, + useCsvExporter, + useCsvImporter, + csv, + type CsvCellIssue, + type AppShellPageProps, +} from "@tailor-platform/app-shell"; + +type Product = { + name: string; + sku: string; + price: number; + quantity: number; + category: string; +}; + +const PRODUCTS: Product[] = [ + { name: "Office chair", sku: "CHAIR-001", price: 249, quantity: 18, category: "Furniture" }, + { name: "Desk lamp", sku: "LAMP-002", price: 79, quantity: 42, category: "Lighting" }, + { name: "Monitor arm", sku: "ARM-003", price: 129, quantity: 9, category: "Accessories" }, +]; + +const CsvFeaturesDemoPage = () => { + const { open: openImporter, props: importerProps } = useCsvImporter({ + schema: { + columns: [ + { + key: "name", + label: "Product Name", + description: "The name of the product", + required: true, + aliases: ["product_name", "productName", "Product"], + schema: csv.string({ min: 1 }), + }, + { + key: "sku", + label: "SKU", + description: "Stock keeping unit", + required: true, + aliases: ["product_sku", "item_code"], + schema: csv.string({ min: 1 }), + }, + { + key: "price", + label: "Price", + description: "Unit price (number)", + required: true, + aliases: ["unit_price", "unitPrice"], + schema: csv.number({ min: 0 }), + }, + { + key: "quantity", + label: "Quantity", + description: "Stock quantity (integer)", + aliases: ["qty", "stock"], + schema: csv.number({ integer: true, min: 0 }), + }, + { + key: "category", + label: "Category", + description: "Product category", + aliases: ["product_category", "type"], + }, + ], + }, + onValidate: async (rows) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + const errors: CsvCellIssue[] = []; + const seenSkus = new Map(); + for (const row of rows) { + const sku = row.data.sku; + if (typeof sku !== "string" || sku === "") continue; + const previousRow = seenSkus.get(sku); + if (previousRow === undefined) { + seenSkus.set(sku, row.rowIndex); + } else { + errors.push({ + rowIndex: row.rowIndex, + columnKey: "sku", + level: "error", + message: `Duplicate SKU "${sku}" (same as row ${previousRow + 1})`, + }); + } + } + return errors; + }, + onImport: async (event) => { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + alert(`Imported ${event.summary.totalRows} products`); + console.log(await event.buildRows()); + }, + }); + + const { open: openExporter, props: exporterProps } = useCsvExporter({ + defaultFilename: "products.csv", + columns: [ + { header: "Product Name", value: (product: Product) => product.name }, + { header: "SKU", value: (product: Product) => product.sku }, + { header: "Price", value: (product: Product) => product.price }, + { header: "Quantity", value: (product: Product) => product.quantity }, + { header: "Category", value: (product: Product) => product.category }, + ], + fetcher: async ({ first, after }) => { + const start = after == null ? 0 : Number(after) + 1; + const rows = PRODUCTS.slice(start, start + first); + const end = start + rows.length; + return { + edges: rows.map((node) => ({ node })), + pageInfo: { + hasNextPage: end < PRODUCTS.length, + endCursor: rows.length > 0 ? String(end - 1) : null, + }, + total: PRODUCTS.length, + }; + }, + }); + + return ( + + + +
+ Import products through a guided validation flow, or download the same data through a + client-side cursor export. +
+ +
+
+

CSV Import

+

+ Upload, map columns, review validation issues, and import products. +

+ +
+ +
+

CSV Export

+

+ Choose a filename, then download every product with progress and cancellation. +

+ +
+
+ + + +
+
+ ); +}; + +CsvFeaturesDemoPage.appShellPageProps = { + meta: { + title: "CSV Features", + }, +} satisfies AppShellPageProps; + +export default CsvFeaturesDemoPage; diff --git a/examples/vite-app/src/pages/showcase/csv-importer/page.tsx b/examples/vite-app/src/pages/showcase/csv-importer/page.tsx deleted file mode 100644 index f91917e87..000000000 --- a/examples/vite-app/src/pages/showcase/csv-importer/page.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { - Layout, - Button, - CsvImporter, - useCsvImporter, - csv, - type CsvCellIssue, - type AppShellPageProps, -} from "@tailor-platform/app-shell"; - -const CsvImporterDemoPage = () => { - const { open, props } = useCsvImporter({ - schema: { - columns: [ - { - key: "name", - label: "Product Name", - description: "The name of the product", - required: true, - aliases: ["product_name", "productName", "Product"], - schema: csv.string({ min: 1 }), - }, - { - key: "sku", - label: "SKU", - description: "Stock keeping unit", - required: true, - aliases: ["product_sku", "item_code"], - schema: csv.string({ min: 1 }), - }, - { - key: "price", - label: "Price", - description: "Unit price (number)", - required: true, - aliases: ["unit_price", "unitPrice"], - schema: csv.number({ min: 0 }), - }, - { - key: "quantity", - label: "Quantity", - description: "Stock quantity (integer)", - aliases: ["qty", "stock"], - schema: csv.number({ integer: true, min: 0 }), - }, - { - key: "category", - label: "Category", - description: "Product category", - aliases: ["product_category", "type"], - }, - ], - }, - onValidate: async (rows) => { - // Simulate server-side validation (uniqueness check on SKU) - await new Promise((resolve) => setTimeout(resolve, 500)); - const errors: CsvCellIssue[] = []; - const seenSkus = new Map(); - for (const row of rows) { - const sku = row.data.sku; - if (typeof sku === "string" && sku !== "") { - const prevIndex = seenSkus.get(sku); - if (prevIndex !== undefined) { - errors.push({ - rowIndex: row.rowIndex, - columnKey: "sku", - level: "error", - message: `Duplicate SKU "${sku}" (same as row ${prevIndex + 1})`, - }); - } else { - seenSkus.set(sku, row.rowIndex); - } - } - } - return errors; - }, - onImport: async (event) => { - // Simulate async import - await new Promise((resolve) => setTimeout(resolve, 1000)); - alert( - `Imported ${event.summary.totalRows} rows (${event.summary.validRows} valid, ${event.summary.correctedRows} corrected, ${event.summary.warningRows} warnings)`, - ); - - // Access the final parsed rows with applied mappings, coercion, and corrections - const rows = await event.buildRows(); - console.log(rows); - }, - }); - - return ( - - - -
-

CSV Importer

-

- A multi-step CSV import wizard: upload a file, map columns to a schema, review & fix - validation issues, then import. Uses useCsvImporter hook for state - management. -

- -
- -
-
- ); -}; - -CsvImporterDemoPage.appShellPageProps = { - meta: { - title: "CSV Importer Demo", - }, -} satisfies AppShellPageProps; - -export default CsvImporterDemoPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index a42147c77..19771a150 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -28,7 +28,7 @@ export type GeneratedRouteParams = { "/showcase/ai-chat": {}; "/showcase/alert-tokens": {}; "/showcase/colors": {}; - "/showcase/csv-importer": {}; + "/showcase/csv-features": {}; "/showcase/data-table": {}; "/showcase/data-table-lab": {}; "/showcase/date-picker": {}; diff --git a/packages/core/src/components/csv-exporter/csv-exporter.tsx b/packages/core/src/components/csv-exporter/csv-exporter.tsx index 7d5a69c7d..2971a6918 100644 --- a/packages/core/src/components/csv-exporter/csv-exporter.tsx +++ b/packages/core/src/components/csv-exporter/csv-exporter.tsx @@ -1,5 +1,4 @@ import { useId, useState } from "react"; -import { Download } from "lucide-react"; import { Button } from "@/components/button"; import { Dialog } from "@/components/dialog"; import { Field } from "@/components/field"; @@ -42,12 +41,6 @@ export function CsvExporterView>({ return ( -
- }> - - {t("csvExporter")} - -
{isExporting ? t("exporting") : t("dialogTitle")} diff --git a/packages/core/src/components/data-table/csv-exporter.tsx b/packages/core/src/components/data-table/csv-exporter.tsx index 437b7ad49..6c6301c05 100644 --- a/packages/core/src/components/data-table/csv-exporter.tsx +++ b/packages/core/src/components/data-table/csv-exporter.tsx @@ -1,4 +1,7 @@ +import { Download } from "lucide-react"; +import { Button } from "@/components/button"; import { CsvExporterView } from "@/components/csv-exporter/csv-exporter"; +import { useCsvExporterT } from "@/components/csv-exporter/i18n"; import type { CsvExporterProps } from "@/components/csv-exporter"; import { useDataTableContext } from "./data-table-context"; @@ -22,8 +25,19 @@ export function DataTableCSVExporter>( props: CsvExporterProps, ) { const { visibleColumns } = useDataTableContext(); + const t = useCsvExporterT(); - return ; + return ( + <> +
+ +
+ + + ); } export type DataTableCSVExporterProps> = From 455125dee591e10aca725d16b536ffdf402b3640 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 18 Sep 2026 10:27:13 +0900 Subject: [PATCH 09/14] fix(csv-exporter): guard empty column exports --- .../core/src/components/csv-exporter/i18n.ts | 2 + .../csv-exporter/use-csv-exporter.test.tsx | 65 +++++++++++++++++++ .../csv-exporter/use-csv-exporter.ts | 17 ++++- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/csv-exporter/i18n.ts b/packages/core/src/components/csv-exporter/i18n.ts index 0ca5bc460..1bda19853 100644 --- a/packages/core/src/components/csv-exporter/i18n.ts +++ b/packages/core/src/components/csv-exporter/i18n.ts @@ -3,6 +3,7 @@ import { defineI18nLabels } from "@/hooks/i18n"; export const csvExporterLabels = defineI18nLabels({ en: { noRows: "No data to export", + noColumns: "Select at least one column to export", exportComplete: (props: { count: number }) => `${props.count.toLocaleString()} rows exported`, exportFailed: "Failed to export CSV", exportCancelled: "CSV export cancelled", @@ -22,6 +23,7 @@ export const csvExporterLabels = defineI18nLabels({ }, ja: { noRows: "エクスポートするデータがありません", + noColumns: "エクスポートする列を1つ以上選択してください", exportComplete: (props: { count: number }) => `${props.count.toLocaleString()} 行をエクスポートしました`, exportFailed: "CSV のエクスポートに失敗しました", diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx index 2b1cb1578..84506d25d 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -111,6 +111,30 @@ describe("useCsvExporter", () => { ); }); + it("rejects an empty DataTable column override before fetching", async () => { + const { column } = createColumnHelper(); + const fetcher = vi.fn(); + const { result } = renderHook( + () => + useCsvExporter({ + defaultFilename: "products.csv", + columns: [column({ id: "name", label: "Name", render: (row) => row.name })], + fetcher, + }), + { wrapper }, + ); + + await act(async () => { + await expect(result.current.props.exporter.exportCsv(undefined, [])).resolves.toBe(false); + }); + + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.props.exporter.phase).toBe("error"); + expect(result.current.props.exporter.error?.message).toBe( + "Select at least one column to export", + ); + }); + it("cancels an in-flight fetch", async () => { const fetcher = vi.fn( ({ signal }: { signal: AbortSignal }) => @@ -141,4 +165,45 @@ describe("useCsvExporter", () => { expect(result.current.props.exporter.phase).toBe("cancelled"); }); }); + + it("aborts an in-flight export when unmounted", async () => { + let resolveFetch!: (connection: { + edges: { node: { name: string } }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }) => void; + let signal!: AbortSignal; + const fetcher = vi.fn(({ signal: nextSignal }: { signal: AbortSignal }) => { + signal = nextSignal; + return new Promise<{ + edges: { node: { name: string } }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }>((resolve) => { + resolveFetch = resolve; + }); + }); + const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + const { result, unmount } = renderHook( + () => + useCsvExporter({ + defaultFilename: "products.csv", + columns: [{ header: "Name", value: (row: { name: string }) => row.name }], + fetcher, + }), + { wrapper }, + ); + + let exportPromise!: Promise; + act(() => { + exportPromise = result.current.props.exporter.exportCsv(); + }); + unmount(); + resolveFetch({ + edges: [{ node: { name: "Alice" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + await expect(exportPromise).resolves.toBe(false); + expect(signal.aborted).toBe(true); + expect(download).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index e9fa479ed..0d545f980 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { unparse } from "papaparse"; import { getCellValue } from "@/components/data-table/cell-renderers"; import type { Column } from "@/components/data-table/types"; @@ -113,6 +113,13 @@ export function useCsvExporter>({ setIsOpen(true); }, []); const abortControllerRef = useRef(null); + useEffect( + () => () => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }, + [], + ); const [phase, setPhase] = useState("idle"); const [progress, setProgress] = useState({ completed: 0, @@ -142,6 +149,13 @@ export function useCsvExporter>({ const columns = resolveColumns( columnOverride && usesDataTableColumns ? columnOverride : columnSource, ); + if (usesDataTableColumns && columns.length === 0) { + const exportError = new Error(t("noColumns")); + setError(exportError); + setPhase("error"); + toast.error(exportError.message); + return false; + } const rows: TRow[] = []; const seenCursors = new Set(); let after: string | null = null; @@ -191,6 +205,7 @@ export function useCsvExporter>({ toast.success(t("exportComplete", { count: rows.length })); return true; } catch (caught) { + if (abortControllerRef.current !== abortController) return false; if ( abortController.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError") From 8160e8734d709e3cbcd29d5ff40d2ca38300dfbe Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 18 Sep 2026 18:48:29 +0900 Subject: [PATCH 10/14] refactor(csv-exporter): use base ui progress --- .../components/csv-exporter/csv-exporter.tsx | 51 +++++++++++-------- .../data-table/csv-exporter.test.tsx | 25 +++++++++ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/core/src/components/csv-exporter/csv-exporter.tsx b/packages/core/src/components/csv-exporter/csv-exporter.tsx index 2971a6918..7bcca81d8 100644 --- a/packages/core/src/components/csv-exporter/csv-exporter.tsx +++ b/packages/core/src/components/csv-exporter/csv-exporter.tsx @@ -1,3 +1,4 @@ +import { Progress } from "@base-ui/react/progress"; import { useId, useState } from "react"; import { Button } from "@/components/button"; import { Dialog } from "@/components/dialog"; @@ -26,10 +27,6 @@ export function CsvExporterView>({ exporter.phase === "fetching" || exporter.phase === "serializing" || exporter.phase === "downloading"; - const total = exporter.progress.total; - const progress = - total != null && total > 0 ? Math.min(100, (exporter.progress.completed / total) * 100) : null; - const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen) setFilename(exporter.defaultFilename); onOpenChange(nextOpen); @@ -51,7 +48,10 @@ export function CsvExporterView>({ {exporter.phase === "fetching" ? ( <>

{t("fetchingRows", exporter.progress)}

- + ) : (
@@ -130,25 +130,34 @@ export function CsvExporter>(props: CsvExpo return ; } -function CsvExportProgress({ label, value }: { label: string; value: number | null }) { - if (value == null) { - return ( -
-
-
- ); - } +function CsvExportProgress({ + label, + completed, + total, +}: { + label: string; + completed: number; + total: number | null; +}) { + const isIndeterminate = total == null || total <= 0; return ( - + className="astw:w-full" + max={isIndeterminate ? 100 : total} + value={isIndeterminate ? null : completed} + > + + + + ); } diff --git a/packages/core/src/components/data-table/csv-exporter.test.tsx b/packages/core/src/components/data-table/csv-exporter.test.tsx index d5305262d..651089d42 100644 --- a/packages/core/src/components/data-table/csv-exporter.test.tsx +++ b/packages/core/src/components/data-table/csv-exporter.test.tsx @@ -47,6 +47,31 @@ describe("DataTable.CSVExporter", () => { error: null, }; + it("reports indeterminate and determinate export progress", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + { wrapper }, + ); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + expect( + screen + .getByRole("progressbar", { name: "0 rows fetched" }) + .hasAttribute("data-indeterminate"), + ).toBe(true); + + rerender( + , + ); + + const progress = screen.getByRole("progressbar", { name: "1 / 2 rows fetched" }); + expect(progress.getAttribute("aria-valuenow")).toBe("1"); + expect(progress.getAttribute("aria-valuemax")).toBe("2"); + }); + it("can reopen an in-progress export dialog", async () => { const user = userEvent.setup(); const exporter: CsvExporterState = { ...idleExporter, phase: "fetching" }; From 8fb0348838ed751caba804c23a87a6f369486c75 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 13 Oct 2026 15:46:21 +0900 Subject: [PATCH 11/14] test(csv-exporter): cover validation and unmount side effects --- .../csv-exporter/use-csv-exporter.test.tsx | 95 +++++++++++-------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx index 84506d25d..c9d801082 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { toast } from "sonner"; import { createColumnHelper } from "@/components/data-table/field-helpers"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { useCsvExporter } from "./use-csv-exporter"; -import type { CsvCursorFetcher, UseCsvExporterOptions } from "./types"; +import type { CsvCursorConnection, CsvCursorFetcher, UseCsvExporterOptions } from "./types"; const wrapper = createAppShellWrapper("en"); @@ -114,6 +115,8 @@ describe("useCsvExporter", () => { it("rejects an empty DataTable column override before fetching", async () => { const { column } = createColumnHelper(); const fetcher = vi.fn(); + const errorToast = vi.spyOn(toast, "error").mockReturnValue("toast"); + const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); const { result } = renderHook( () => useCsvExporter({ @@ -133,6 +136,8 @@ describe("useCsvExporter", () => { expect(result.current.props.exporter.error?.message).toBe( "Select at least one column to export", ); + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Select at least one column to export"); + expect(download).not.toHaveBeenCalled(); }); it("cancels an in-flight fetch", async () => { @@ -166,44 +171,54 @@ describe("useCsvExporter", () => { }); }); - it("aborts an in-flight export when unmounted", async () => { - let resolveFetch!: (connection: { - edges: { node: { name: string } }[]; - pageInfo: { hasNextPage: boolean; endCursor: string | null }; - }) => void; - let signal!: AbortSignal; - const fetcher = vi.fn(({ signal: nextSignal }: { signal: AbortSignal }) => { - signal = nextSignal; - return new Promise<{ - edges: { node: { name: string } }[]; - pageInfo: { hasNextPage: boolean; endCursor: string | null }; - }>((resolve) => { - resolveFetch = resolve; + it.each(["resolves", "rejects"])( + "aborts an in-flight export when unmounted even if the fetch later %s", + async (outcome) => { + let resolveFetch!: (connection: CsvCursorConnection) => void; + let rejectFetch!: (error: Error) => void; + let signal!: AbortSignal; + const fetcher = vi.fn(({ signal: nextSignal }: { signal: AbortSignal }) => { + signal = nextSignal; + return new Promise>((resolve, reject) => { + resolveFetch = resolve; + rejectFetch = reject; + }); }); - }); - const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); - const { result, unmount } = renderHook( - () => - useCsvExporter({ - defaultFilename: "products.csv", - columns: [{ header: "Name", value: (row: { name: string }) => row.name }], - fetcher, - }), - { wrapper }, - ); - - let exportPromise!: Promise; - act(() => { - exportPromise = result.current.props.exporter.exportCsv(); - }); - unmount(); - resolveFetch({ - edges: [{ node: { name: "Alice" } }], - pageInfo: { hasNextPage: false, endCursor: null }, - }); - - await expect(exportPromise).resolves.toBe(false); - expect(signal.aborted).toBe(true); - expect(download).not.toHaveBeenCalled(); - }); + const errorToast = vi.spyOn(toast, "error").mockReturnValue("toast"); + const infoToast = vi.spyOn(toast, "info").mockReturnValue("toast"); + const successToast = vi.spyOn(toast, "success").mockReturnValue("toast"); + const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + const { result, unmount } = renderHook( + () => + useCsvExporter({ + defaultFilename: "products.csv", + columns: [{ header: "Name", value: (row: { name: string }) => row.name }], + fetcher, + }), + { wrapper }, + ); + + let exportPromise!: Promise; + act(() => { + exportPromise = result.current.props.exporter.exportCsv(); + }); + unmount(); + expect(signal.aborted).toBe(true); + if (outcome === "resolves") { + resolveFetch({ + edges: [{ node: { name: "Alice" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + } else { + rejectFetch(new Error("Fetch failed after unmount")); + } + + await expect(exportPromise).resolves.toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(download).not.toHaveBeenCalled(); + expect(errorToast).not.toHaveBeenCalled(); + expect(infoToast).not.toHaveBeenCalled(); + expect(successToast).not.toHaveBeenCalled(); + }, + ); }); From e0cefe0aa7e00e5ddb1078752c818836d32a794d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 13 Oct 2026 17:06:39 +0900 Subject: [PATCH 12/14] refactor(csv-exporter): align fetched pages with DataTable rows --- .changeset/bright-csv-exporter.md | 2 +- docs-manifest.json | 26 +++++++++---------- .../components/csv-exporter.docs.outline.md | 6 ++--- .../components/data-table.docs.outline.md | 2 +- .../hooks/use-csv-exporter.docs.outline.md | 14 +++++++--- docs/api/use-csv-exporter.md | 14 +++++++--- docs/components/csv-exporter.md | 6 ++--- docs/components/data-table.md | 2 +- .../src/pages/showcase/csv-features/page.tsx | 2 +- .../pages/showcase/data-table-lab/page.tsx | 2 +- .../src/pages/showcase/data-table/page.tsx | 17 +++--------- .../components/csv-exporter/csv-exporter.tsx | 2 +- .../core/src/components/csv-exporter/index.ts | 2 +- .../core/src/components/csv-exporter/types.ts | 8 +++--- .../csv-exporter/use-csv-exporter.test.tsx | 14 +++++----- .../csv-exporter/use-csv-exporter.ts | 21 +++++++-------- packages/core/src/index.ts | 2 +- 17 files changed, 72 insertions(+), 70 deletions(-) diff --git a/.changeset/bright-csv-exporter.md b/.changeset/bright-csv-exporter.md index 712d731bf..8389d3d1e 100644 --- a/.changeset/bright-csv-exporter.md +++ b/.changeset/bright-csv-exporter.md @@ -2,7 +2,7 @@ "@tailor-platform/app-shell": minor --- -Add `useCsvExporter` and the standalone `CsvExporter` dialog for client-side, cursor-paginated CSV downloads with filename editing, progress, cancellation, UTF-8 BOM, and spreadsheet formula escaping. It is data-client and DataTable independent, so it can be opened from any button or collection UI. +Add `useCsvExporter` and the standalone `CsvExporter` dialog for client-side, cursor-paginated CSV downloads with filename editing, progress, cancellation, UTF-8 BOM, and spreadsheet formula escaping. It is data-client and DataTable independent, so it can be opened from any button or collection UI. Fetchers return `CsvExportPage` values shaped as `{ rows, pageInfo, total? }`, using the same row representation as DataTable. ```tsx const { open, props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); diff --git a/docs-manifest.json b/docs-manifest.json index 9eebc5a50..5b3c3ebdd 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -341,10 +341,10 @@ "sources": ["packages/core/src/components/csv-exporter/**"], "claims": [], "symbols": [ - "CsvCursorConnection", "CsvCursorFetcher", "CsvExportCell", "CsvExportColumn", + "CsvExportPage", "CsvExportPhase", "CsvExporter", "CsvExporterProps", @@ -353,10 +353,10 @@ "useCsvExporter" ], "hashes": { - "typeSurface": "60dbd72ac4ace359", - "outline": "042dd8520918d281", + "typeSurface": "b5d2153372721fdd", + "outline": "98eebfc8c7ecd93e", "snapshot": null, - "outputMd": "1d0086dceb942fec", + "outputMd": "4c92d382e56b51ef", "examples": null } }, @@ -476,9 +476,9 @@ ], "hashes": { "typeSurface": "53eef62bcaae1c1c", - "outline": "fddc86fd56a26359", + "outline": "b6c43130ae349330", "snapshot": null, - "outputMd": "0e9991ffc02884d4", + "outputMd": "923c971fe2b5fef2", "examples": null } }, @@ -1538,10 +1538,10 @@ ], "claims": [], "symbols": [ - "CsvCursorConnection", "CsvCursorFetcher", "CsvExportCell", "CsvExportColumn", + "CsvExportPage", "CsvExportPhase", "CsvExporterProps", "CsvExporterState", @@ -1549,10 +1549,10 @@ "useCsvExporter" ], "hashes": { - "typeSurface": "e23f78efb13d44e1", - "outline": "5ea3b4ace553ed0b", + "typeSurface": "0aa0163146f1c3d9", + "outline": "79cafab994941e8a", "snapshot": null, - "outputMd": "5f10cd5db2dd199a", + "outputMd": "0da85a472e432125", "examples": null } }, @@ -1734,9 +1734,9 @@ "packages/core/skills/app-shell-patterns/references/components/checkbox.md": "14f7ba23f441df38", "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", - "packages/core/skills/app-shell-patterns/references/components/csv-exporter.md": "1d0086dceb942fec", + "packages/core/skills/app-shell-patterns/references/components/csv-exporter.md": "4c92d382e56b51ef", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "0e9991ffc02884d4", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "923c971fe2b5fef2", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "f39002ad79625dc4", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1780,7 +1780,7 @@ "packages/core/skills/app-shell-patterns/references/api/use-app-shell-config.md": "1a0a937f3a412c13", "packages/core/skills/app-shell-patterns/references/api/use-app-shell-data.md": "2d2eaf5aa04d95d3", "packages/core/skills/app-shell-patterns/references/api/use-auth.md": "a7d907ac79d24944", - "packages/core/skills/app-shell-patterns/references/api/use-csv-exporter.md": "5f10cd5db2dd199a", + "packages/core/skills/app-shell-patterns/references/api/use-csv-exporter.md": "0da85a472e432125", "packages/core/skills/app-shell-patterns/references/api/use-override-breadcrumb.md": "6ec55fc3dfd8c21d", "packages/core/skills/app-shell-patterns/references/api/use-page-meta.md": "f988bdc0fb180f0c", "packages/core/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md": "7144ae3dc68aa65b", diff --git a/docs-src/components/csv-exporter.docs.outline.md b/docs-src/components/csv-exporter.docs.outline.md index 20fac4faf..b659247cc 100644 --- a/docs-src/components/csv-exporter.docs.outline.md +++ b/docs-src/components/csv-exporter.docs.outline.md @@ -9,7 +9,7 @@ sources: # CsvExporter -`CsvExporter` is the standalone UI for [`useCsvExporter`](../api/use-csv-exporter.md). It provides a trigger button, editable filename dialog, fetch progress, and cancellation without requiring a DataTable. +`CsvExporter` is the standalone UI for [`useCsvExporter`](../api/use-csv-exporter.md). It provides an editable filename dialog, fetch progress, and cancellation without requiring a DataTable. It does not render a trigger button; use the hook's `open()` action to open it from your own button or action. ## Import @@ -43,11 +43,11 @@ const { open, props } = useCsvExporter({ | `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | | `exporter` | `CsvExporterState` | Export state and actions returned by `useCsvExporter`. | -The filename input is validated through AppShell `Form` and `Field`. While fetching, the dialog shows fetched rows and a determinate progress bar when the connection returns `total`. The user can cancel the request, or dismiss and reopen the dialog while the export continues. +The filename input is validated through AppShell `Form` and `Field`. While fetching, the dialog shows fetched rows and a determinate progress bar when the fetcher returns `total`. The user can cancel the request, or dismiss and reopen the dialog while the export continues. ## DataTable -For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props: +For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props. This variant includes its own trigger button: ```tsx diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 26f09f228..7390adc1b 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -222,7 +222,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi | `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | | `exporter` | `CsvExporterState` | Export state and actions from [`useCsvExporter`](../api/use-csv-exporter.md). | -`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: +`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the fetcher returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: ```tsx diff --git a/docs-src/hooks/use-csv-exporter.docs.outline.md b/docs-src/hooks/use-csv-exporter.docs.outline.md index fc88b2818..a76c7eea0 100644 --- a/docs-src/hooks/use-csv-exporter.docs.outline.md +++ b/docs-src/hooks/use-csv-exporter.docs.outline.md @@ -10,7 +10,7 @@ sources: # useCsvExporter -`useCsvExporter` fetches every page from a Relay-style cursor connection, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. +`useCsvExporter` fetches every cursor-paginated page of rows, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. ## Import @@ -18,7 +18,7 @@ sources: import { CsvExporter, useCsvExporter, - type CsvCursorConnection, + type CsvExportPage, type CsvExportColumn, } from "@tailor-platform/app-shell"; ``` @@ -42,14 +42,20 @@ const { props } = useCsvExporter({ .toPromise(); if (result.error) throw result.error; - return result.data?.products; + const connection = result.data?.products; + if (!connection) return connection; + return { + rows: connection.edges.map(({ node }) => node), + pageInfo: connection.pageInfo, + total: connection.total, + }; }, }); ; ``` -The fetcher receives the cursor parameters for one page. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. +The fetcher receives the cursor parameters for one page and returns a `CsvExportPage` shaped as `{ rows, pageInfo, total? }`, using the same row representation as DataTable. `pageInfo` must include `hasNextPage` and `endCursor`; `total` is optional. For a GraphQL connection, extract the nodes into `rows` in the fetcher, as shown above. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. Include `total` in the connection query to show a determinate progress bar: diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md index 6381a9550..653aea5e8 100644 --- a/docs/api/use-csv-exporter.md +++ b/docs/api/use-csv-exporter.md @@ -7,7 +7,7 @@ description: Fetch all cursor-paginated rows and download them as a CSV file wit # useCsvExporter -`useCsvExporter` fetches every page from a Relay-style cursor connection, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. +`useCsvExporter` fetches every cursor-paginated page of rows, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. ## Import @@ -15,7 +15,7 @@ description: Fetch all cursor-paginated rows and download them as a CSV file wit import { CsvExporter, useCsvExporter, - type CsvCursorConnection, + type CsvExportPage, type CsvExportColumn, } from "@tailor-platform/app-shell"; ``` @@ -39,14 +39,20 @@ const { props } = useCsvExporter({ .toPromise(); if (result.error) throw result.error; - return result.data?.products; + const connection = result.data?.products; + if (!connection) return connection; + return { + rows: connection.edges.map(({ node }) => node), + pageInfo: connection.pageInfo, + total: connection.total, + }; }, }); ; ``` -The fetcher receives the cursor parameters for one page. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. +The fetcher receives the cursor parameters for one page and returns a `CsvExportPage` shaped as `{ rows, pageInfo, total? }`, using the same row representation as DataTable. `pageInfo` must include `hasNextPage` and `endCursor`; `total` is optional. For a GraphQL connection, extract the nodes into `rows` in the fetcher, as shown above. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. Include `total` in the connection query to show a determinate progress bar: diff --git a/docs/components/csv-exporter.md b/docs/components/csv-exporter.md index 9c002b5f2..033326a53 100644 --- a/docs/components/csv-exporter.md +++ b/docs/components/csv-exporter.md @@ -7,7 +7,7 @@ description: Filename dialog and progress UI for client-side cursor-paginated CS # CsvExporter -`CsvExporter` is the standalone UI for [`useCsvExporter`](../api/use-csv-exporter.md). It provides a trigger button, editable filename dialog, fetch progress, and cancellation without requiring a DataTable. +`CsvExporter` is the standalone UI for [`useCsvExporter`](../api/use-csv-exporter.md). It provides an editable filename dialog, fetch progress, and cancellation without requiring a DataTable. It does not render a trigger button; use the hook's `open()` action to open it from your own button or action. ## Import @@ -41,11 +41,11 @@ const { open, props } = useCsvExporter({ | `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | | `exporter` | `CsvExporterState` | Export state and actions returned by `useCsvExporter`. | -The filename input is validated through AppShell `Form` and `Field`. While fetching, the dialog shows fetched rows and a determinate progress bar when the connection returns `total`. The user can cancel the request, or dismiss and reopen the dialog while the export continues. +The filename input is validated through AppShell `Form` and `Field`. While fetching, the dialog shows fetched rows and a determinate progress bar when the fetcher returns `total`. The user can cancel the request, or dismiss and reopen the dialog while the export continues. ## DataTable -For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props: +For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props. This variant includes its own trigger button: ```tsx diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 5851e3960..64cb74a35 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -216,7 +216,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi | `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | | `exporter` | `CsvExporterState` | Export state and actions from [`useCsvExporter`](../api/use-csv-exporter.md). | -`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the connection returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: +`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the fetcher returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: ```tsx diff --git a/examples/vite-app/src/pages/showcase/csv-features/page.tsx b/examples/vite-app/src/pages/showcase/csv-features/page.tsx index 9b776113b..5b350e708 100644 --- a/examples/vite-app/src/pages/showcase/csv-features/page.tsx +++ b/examples/vite-app/src/pages/showcase/csv-features/page.tsx @@ -109,7 +109,7 @@ const CsvFeaturesDemoPage = () => { const rows = PRODUCTS.slice(start, start + first); const end = start + rows.length; return { - edges: rows.map((node) => ({ node })), + rows, pageInfo: { hasNextPage: end < PRODUCTS.length, endCursor: rows.length > 0 ? String(end - 1) : null, diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index 12d94da06..f3f708be6 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx @@ -524,7 +524,7 @@ const DataTableLabPage = () => { defaultFilename: "invoices.csv", columns: baseColumns, fetcher: async () => ({ - edges: rows.map((node) => ({ node })), + rows, pageInfo: { hasNextPage: false, endCursor: null }, total: rows.length, }), diff --git a/examples/vite-app/src/pages/showcase/data-table/page.tsx b/examples/vite-app/src/pages/showcase/data-table/page.tsx index eada3966d..77e8eb316 100644 --- a/examples/vite-app/src/pages/showcase/data-table/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table/page.tsx @@ -140,7 +140,7 @@ function matchesQuery(row: Invoice, query: CollectionVariables["query"]): boolea ); } -async function queryInvoices(variables: CollectionVariables): Promise> { +async function queryInvoices(variables: CollectionVariables) { // 1. filter let rows = ALL_INVOICES.filter((r) => matchesQuery(r, variables.query)); @@ -346,21 +346,12 @@ function InvoiceTable({ defaultFilename: "invoices.csv", columns, pageSize: 10, - fetcher: async ({ first, after }) => { - const page = await queryInvoices({ + fetcher: ({ first, after }) => + queryInvoices({ query: variables.query, order: variables.order, pagination: { first, after }, - }); - return { - edges: page.rows.map((node) => ({ node })), - pageInfo: page.pageInfo ?? { - hasNextPage: false, - endCursor: null, - }, - total: page.total ?? null, - }; - }, + }), }); return ( diff --git a/packages/core/src/components/csv-exporter/csv-exporter.tsx b/packages/core/src/components/csv-exporter/csv-exporter.tsx index 7bcca81d8..43bb6f26f 100644 --- a/packages/core/src/components/csv-exporter/csv-exporter.tsx +++ b/packages/core/src/components/csv-exporter/csv-exporter.tsx @@ -109,7 +109,7 @@ export function CsvExporterView>({ } /** - * Standard CSV export button, filename dialog, and progress UI. + * CSV filename dialog and progress UI, opened by a caller-provided trigger. * * @example * ```tsx diff --git a/packages/core/src/components/csv-exporter/index.ts b/packages/core/src/components/csv-exporter/index.ts index 3a95365f3..f28aab1fb 100644 --- a/packages/core/src/components/csv-exporter/index.ts +++ b/packages/core/src/components/csv-exporter/index.ts @@ -1,7 +1,7 @@ export { CsvExporter } from "./csv-exporter"; export { useCsvExporter } from "./use-csv-exporter"; export type { - CsvCursorConnection, + CsvExportPage, CsvCursorFetcher, CsvExportCell, CsvExportColumn, diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts index 7033efe1c..f891e8674 100644 --- a/packages/core/src/components/csv-exporter/types.ts +++ b/packages/core/src/components/csv-exporter/types.ts @@ -22,9 +22,9 @@ export interface CsvExportColumn { value: (row: TRow) => CsvExportCell; } -/** A Relay-style cursor page returned by a CSV export data source. */ -export interface CsvCursorConnection { - edges: readonly { node: TRow }[]; +/** A cursor page of rows returned by a CSV export data source. */ +export interface CsvExportPage { + rows: readonly TRow[]; pageInfo: { hasNextPage: boolean; endCursor: string | null; @@ -43,7 +43,7 @@ export type CsvCursorFetcher = (pagination: { first: number; after: string | null; signal: AbortSignal; -}) => Promise | null | undefined>; +}) => Promise | null | undefined>; type CsvExportableDataTableColumn> = | (Column & { label?: undefined }) diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx index c9d801082..a2c6940dc 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -4,7 +4,7 @@ import { toast } from "sonner"; import { createColumnHelper } from "@/components/data-table/field-helpers"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { useCsvExporter } from "./use-csv-exporter"; -import type { CsvCursorConnection, CsvCursorFetcher, UseCsvExporterOptions } from "./types"; +import type { CsvExportPage, CsvCursorFetcher, UseCsvExporterOptions } from "./types"; const wrapper = createAppShellWrapper("en"); @@ -63,14 +63,14 @@ describe("useCsvExporter", () => { column({ render: () => }), ]; const fetcher = vi - .fn() + .fn>() .mockResolvedValueOnce({ - edges: [{ node: { id: "1", name: "Alice", formula: "=SUM(A1:A2)" } }], + rows: [{ id: "1", name: "Alice", formula: "=SUM(A1:A2)" }], pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, total: 2, }) .mockResolvedValueOnce({ - edges: [{ node: { id: "2", name: "Bob", formula: "safe" } }], + rows: [{ id: "2", name: "Bob", formula: "safe" }], pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, total: 2, }); @@ -174,12 +174,12 @@ describe("useCsvExporter", () => { it.each(["resolves", "rejects"])( "aborts an in-flight export when unmounted even if the fetch later %s", async (outcome) => { - let resolveFetch!: (connection: CsvCursorConnection) => void; + let resolveFetch!: (page: CsvExportPage) => void; let rejectFetch!: (error: Error) => void; let signal!: AbortSignal; const fetcher = vi.fn(({ signal: nextSignal }: { signal: AbortSignal }) => { signal = nextSignal; - return new Promise>((resolve, reject) => { + return new Promise>((resolve, reject) => { resolveFetch = resolve; rejectFetch = reject; }); @@ -206,7 +206,7 @@ describe("useCsvExporter", () => { expect(signal.aborted).toBe(true); if (outcome === "resolves") { resolveFetch({ - edges: [{ node: { name: "Alice" } }], + rows: [{ name: "Alice" }], pageInfo: { hasNextPage: false, endCursor: null }, }); } else { diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index 0d545f980..6d55f24ed 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -5,7 +5,7 @@ import type { Column } from "@/components/data-table/types"; import { useToast } from "@/hooks/use-toast"; import { useCsvExporterT } from "./i18n"; import type { - CsvCursorConnection, + CsvExportPage, CsvExportCell, CsvExportColumn, CsvExporterState, @@ -79,7 +79,7 @@ function downloadCsv(csv: string, filename: string) { /** * Fetch every cursor page, serialize its rows as CSV, and expose progress and * cancellation actions. The data client stays outside this hook: `fetcher` - * only needs to return a Relay-style cursor connection. + * only needs to return a cursor page with `rows` and `pageInfo`. * * @example * ```tsx @@ -87,8 +87,7 @@ function downloadCsv(csv: string, filename: string) { * defaultFilename: "products.csv", * columns, * fetcher: async ({ first, after, signal }) => { - * const result = await client.listProducts({ first, after, signal }); - * return result.connection; + * return client.listProducts({ first, after, signal }); * }, * }); * @@ -162,20 +161,20 @@ export function useCsvExporter>({ let total: number | null = null; for (;;) { - const connection = await fetcher({ + const page = await fetcher({ first: pageSize, after, signal: abortController.signal, }); if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); - if (!connection) break; + if (!page) break; - rows.push(...connection.edges.map(({ node }) => node)); - total ??= connection.total ?? null; + rows.push(...page.rows); + total ??= page.total ?? null; setProgress({ completed: rows.length, total }); - if (!connection.pageInfo.hasNextPage) break; - const next = connection.pageInfo.endCursor; + if (!page.pageInfo.hasNextPage) break; + const next = page.pageInfo.endCursor; if (next == null || seenCursors.has(next)) { throw new Error("CSV export pagination did not advance"); } @@ -239,4 +238,4 @@ export function useCsvExporter>({ return { open, props }; } -export type { CsvCursorConnection }; +export type { CsvExportPage }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 16b6e75d6..71a857200 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -259,7 +259,7 @@ export { export { CsvExporter, useCsvExporter, - type CsvCursorConnection, + type CsvExportPage, type CsvCursorFetcher, type CsvExportCell, type CsvExportColumn, From 383279e22e2f59858961feb27c5c8882e2597080 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 14 Oct 2026 11:28:57 +0900 Subject: [PATCH 13/14] feat(data-table): add dedicated CSV exporter hook --- .changeset/bright-csv-exporter.md | 11 +- docs-manifest.json | 46 +++- .../components/csv-exporter.docs.outline.md | 13 +- .../components/data-table.docs.outline.md | 24 +- .../hooks/use-csv-exporter.docs.outline.md | 19 +- ...se-data-table-csv-exporter.docs.outline.md | 76 ++++++ docs/api/use-csv-exporter.md | 19 +- docs/api/use-data-table-csv-exporter.md | 74 ++++++ docs/components/csv-exporter.md | 13 +- docs/components/data-table.md | 24 +- .../pages/showcase/data-table-lab/page.tsx | 5 +- .../src/pages/showcase/data-table/page.tsx | 5 +- .../components/csv-exporter/csv-exporter.tsx | 16 +- .../core/src/components/csv-exporter/types.ts | 13 +- .../csv-exporter/use-csv-export-controller.ts | 220 ++++++++++++++++ .../csv-exporter/use-csv-exporter.ts | 240 ++---------------- .../data-table/csv-exporter.test.tsx | 205 +++++++++++---- .../components/data-table/csv-exporter.tsx | 23 +- .../core/src/components/data-table/index.ts | 4 + .../use-data-table-csv-exporter.test.tsx | 163 ++++++++++++ .../data-table/use-data-table-csv-exporter.ts | 52 ++++ packages/core/src/index.ts | 2 + 22 files changed, 904 insertions(+), 363 deletions(-) create mode 100644 docs-src/hooks/use-data-table-csv-exporter.docs.outline.md create mode 100644 docs/api/use-data-table-csv-exporter.md create mode 100644 packages/core/src/components/csv-exporter/use-csv-export-controller.ts create mode 100644 packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx create mode 100644 packages/core/src/components/data-table/use-data-table-csv-exporter.ts diff --git a/.changeset/bright-csv-exporter.md b/.changeset/bright-csv-exporter.md index 8389d3d1e..a10bf4606 100644 --- a/.changeset/bright-csv-exporter.md +++ b/.changeset/bright-csv-exporter.md @@ -11,8 +11,15 @@ const { open, props } = useCsvExporter({ defaultFilename: "products.csv", column ; ``` -Add `DataTable.CSVExporter`, which accepts the same props and, when the hook receives DataTable columns, exports the user's current visible columns in their selected order. +Add `useDataTableCsvExporter` and `DataTable.CSVExporter` for table-integrated exports. The dedicated hook takes no column configuration and can be called outside `DataTable.Root`; the component accepts only its branded exporter props and supplies the enclosing table's visible columns in their current order when a download starts. Changing the table layout does not change an export already in progress, and hiding all exportable columns fails before fetching. ```tsx - +const { props: tableExporter } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + + + +; ``` diff --git a/docs-manifest.json b/docs-manifest.json index 5b3c3ebdd..f0bf260e4 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -354,9 +354,9 @@ ], "hashes": { "typeSurface": "b5d2153372721fdd", - "outline": "98eebfc8c7ecd93e", + "outline": "1b4bdf78e1a3c576", "snapshot": null, - "outputMd": "4c92d382e56b51ef", + "outputMd": "216f1bb6310cc04a", "examples": null } }, @@ -462,6 +462,7 @@ "TableOrderableFieldName", "UseCollectionOptions", "UseCollectionReturn", + "UseDataTableCsvExporterOptions", "UseDataTableOptions", "UseDataTableReturn", "createColumnHelper", @@ -471,14 +472,15 @@ "useCollectionVariables", "useDataTable", "useDataTableContext", + "useDataTableCsvExporter", "useURLCollectionVariables", "withURLCollectionState" ], "hashes": { - "typeSurface": "53eef62bcaae1c1c", - "outline": "b6c43130ae349330", + "typeSurface": "d2ca1e5ef49d8b50", + "outline": "47835514a6f2ac6f", "snapshot": null, - "outputMd": "923c971fe2b5fef2", + "outputMd": "d484ede845344e23", "examples": null } }, @@ -1550,9 +1552,30 @@ ], "hashes": { "typeSurface": "0aa0163146f1c3d9", - "outline": "79cafab994941e8a", + "outline": "e8496bb30fac3fd1", "snapshot": null, - "outputMd": "0da85a472e432125", + "outputMd": "b6fc07903fdca468", + "examples": null + } + }, + "use-data-table-csv-exporter": { + "slug": "use-data-table-csv-exporter", + "kind": "code-backed", + "outline": "docs-src/hooks/use-data-table-csv-exporter.docs.outline.md", + "output": "docs/api/use-data-table-csv-exporter.md", + "examples": null, + "sources": ["packages/core/src/components/data-table/use-data-table-csv-exporter.ts"], + "claims": [], + "symbols": [ + "DataTableCSVExporterProps", + "UseDataTableCsvExporterOptions", + "useDataTableCsvExporter" + ], + "hashes": { + "typeSurface": "ae941a345a689328", + "outline": "065d8c99523aade8", + "snapshot": null, + "outputMd": "843f40c6595c82c8", "examples": null } }, @@ -1734,9 +1757,9 @@ "packages/core/skills/app-shell-patterns/references/components/checkbox.md": "14f7ba23f441df38", "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", - "packages/core/skills/app-shell-patterns/references/components/csv-exporter.md": "4c92d382e56b51ef", + "packages/core/skills/app-shell-patterns/references/components/csv-exporter.md": "216f1bb6310cc04a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "923c971fe2b5fef2", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "d484ede845344e23", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "f39002ad79625dc4", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1780,7 +1803,8 @@ "packages/core/skills/app-shell-patterns/references/api/use-app-shell-config.md": "1a0a937f3a412c13", "packages/core/skills/app-shell-patterns/references/api/use-app-shell-data.md": "2d2eaf5aa04d95d3", "packages/core/skills/app-shell-patterns/references/api/use-auth.md": "a7d907ac79d24944", - "packages/core/skills/app-shell-patterns/references/api/use-csv-exporter.md": "0da85a472e432125", + "packages/core/skills/app-shell-patterns/references/api/use-csv-exporter.md": "b6fc07903fdca468", + "packages/core/skills/app-shell-patterns/references/api/use-data-table-csv-exporter.md": "843f40c6595c82c8", "packages/core/skills/app-shell-patterns/references/api/use-override-breadcrumb.md": "6ec55fc3dfd8c21d", "packages/core/skills/app-shell-patterns/references/api/use-page-meta.md": "f988bdc0fb180f0c", "packages/core/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md": "7144ae3dc68aa65b", @@ -1800,6 +1824,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "packages/core/skills/app-shell-patterns/SKILL.md": "23a0b42c86722799" + "packages/core/skills/app-shell-patterns/SKILL.md": "40754796f5d7fb31" } } diff --git a/docs-src/components/csv-exporter.docs.outline.md b/docs-src/components/csv-exporter.docs.outline.md index b659247cc..0c81263f6 100644 --- a/docs-src/components/csv-exporter.docs.outline.md +++ b/docs-src/components/csv-exporter.docs.outline.md @@ -47,10 +47,17 @@ The filename input is validated through AppShell `Form` and `Field`. While fetch ## DataTable -For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props. This variant includes its own trigger button: +For an export coupled to a table, use [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md) with [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter). The dedicated hook takes no `columns` option and can be called outside `DataTable.Root`: ```tsx - +const { props: tableExporter } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + + + +; ``` -When the hook is configured with DataTable columns, this variant exports only visible columns in their current user-defined order. +This variant includes its own trigger button and always exports the enclosing table's visible columns in their current order at download start. It accepts only the dedicated hook's branded exporter props, not the result of `useCsvExporter`. Use standalone `CsvExporter` for a fixed schema or custom report columns. diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 7390adc1b..6a2d424c5 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -22,6 +22,7 @@ import { DataTable, useDataTable, useDataTableContext, + useDataTableCsvExporter, useCollectionVariables, createColumnHelper, type Column, @@ -161,7 +162,7 @@ function JournalsPage() { | `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | | `DataTable.Footer` | Footer container for pagination and other footer content. Optional. Becomes the bulk-action bar while rows are selected — see [Selection actions](#selection-actions). | | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | -| `DataTable.CSVExporter` | Standard CSV download button and filename/progress dialog. Pass an exporter from [`useCsvExporter`](../api/use-csv-exporter.md) through `exporter`. | +| `DataTable.CSVExporter` | CSV download button and filename/progress dialog. Pass the dedicated props from [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md). | ### `DataTable.Root` Props @@ -216,15 +217,22 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi ### `DataTable.CSVExporter` Props -| Prop | Type | Description | -| -------------- | ------------------------- | ----------------------------------------------------------------------------- | -| `open` | `boolean` | Whether the exporter dialog is open. | -| `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | -| `exporter` | `CsvExporterState` | Export state and actions from [`useCsvExporter`](../api/use-csv-exporter.md). | +| Prop | Type | Description | +| -------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `open` | `boolean` | Whether the exporter dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | +| `exporter` | `DataTableCSVExporterProps["exporter"]` | Branded export state from [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md). | -`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the fetcher returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: +`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the fetcher returns `total`; it also provides a cancel action. It accepts only props from `useDataTableCsvExporter`, whose `columns` option is intentionally unavailable. The hook can be called outside `DataTable.Root`; the component reads the enclosing table's visible columns and their order when the download starts. Hiding all exportable columns fails before fetching. Changes to visibility or order after an export starts apply to the next export, not the in-flight CSV. For a fixed schema or custom report columns, use standalone [`CsvExporter`](./csv-exporter.md) with `useCsvExporter` instead. + +Compose the toolbar layout normally inside `DataTable.Root` when the exporter should sit immediately before the built-in Columns control: ```tsx +const { props } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + @@ -235,7 +243,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi - +; ``` ### `DataTable.Pagination` Props diff --git a/docs-src/hooks/use-csv-exporter.docs.outline.md b/docs-src/hooks/use-csv-exporter.docs.outline.md index a76c7eea0..c8e33589c 100644 --- a/docs-src/hooks/use-csv-exporter.docs.outline.md +++ b/docs-src/hooks/use-csv-exporter.docs.outline.md @@ -10,7 +10,7 @@ sources: # useCsvExporter -`useCsvExporter` fetches every cursor-paginated page of rows, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. +`useCsvExporter` fetches every cursor-paginated page of rows, creates a UTF-8 CSV, and returns props for the standalone [`CsvExporter`](../components/csv-exporter.md). It is client-agnostic: the calling application implements the `fetcher` with its own data client. ## Import @@ -26,7 +26,7 @@ import { ## Usage ```tsx -const { props } = useCsvExporter({ +const { open, props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher: async ({ first, after, signal }) => { @@ -52,7 +52,8 @@ const { props } = useCsvExporter({ }, }); -; + +; ``` The fetcher receives the cursor parameters for one page and returns a `CsvExportPage` shaped as `{ rows, pageInfo, total? }`, using the same row representation as DataTable. `pageInfo` must include `hasNextPage` and `endCursor`; `total` is optional. For a GraphQL connection, extract the nodes into `rows` in the fetcher, as shown above. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. @@ -82,7 +83,7 @@ Without `total`, the progress dialog shows the number of rows fetched but not a | Option | Type | Description | | ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | | `defaultFilename` | `string` | Initial file name shown in the dialog. A `.csv` suffix is added when omitted. | -| `columns` | `CsvExportColumn[] \| Column[]` | Explicit CSV columns, or DataTable columns to derive them from. | +| `columns` | `CsvExportColumn[] \| Column[]` | Required. Explicit CSV columns, or DataTable columns to derive them from. | | `fetcher` | `CsvCursorFetcher` | Fetches one cursor page. Required. | | `pageSize` | `number` | Rows requested per page. Defaults to `1000`. | @@ -98,13 +99,13 @@ const exporter = useCsvExporter({ }); ``` -A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. When the exporter is passed to `DataTable.CSVExporter`, the current visible DataTable columns and their user-defined order are used. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table or must ignore user column settings. +A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. The supplied columns define the standalone schema; this hook does not read DataTable context or follow its visibility and ordering changes. Use explicit `CsvExportColumn[]` definitions for custom headers or value formatting. To export a table's current visible-column layout, use [`useDataTableCsvExporter`](./use-data-table-csv-exporter.md) with `DataTable.CSVExporter` instead; that component rejects standalone exporter props. ## Return value -| Property | Type | Description | -| -------- | ------------------------ | -------------------------------------------------------------------------------------------- | -| `open()` | `() => void` | Opens the standard exporter UI. | -| `props` | `CsvExporterProps` | Spread directly onto `` or ``. | +| Property | Type | Description | +| -------- | ------------------------ | ------------------------------------------------------------------------------------------- | +| `open()` | `() => void` | Opens the standard exporter UI. | +| `props` | `CsvExporterProps` | Spread directly onto ``. Not accepted by `DataTable.CSVExporter`. | CSV cells are escaped using RFC 4180-compatible quoting, UTF-8 BOM is included for spreadsheet compatibility, and formula-like cell values are escaped before download. diff --git a/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md b/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md new file mode 100644 index 000000000..9f78887bc --- /dev/null +++ b/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md @@ -0,0 +1,76 @@ +--- +kind: code-backed +group: use-data-table-csv-exporter +title: useDataTableCsvExporter +description: Prepare a CSV export whose columns come from the enclosing DataTable at download start +sources: + - packages/core/src/components/data-table/use-data-table-csv-exporter.ts +--- + +# useDataTableCsvExporter + +`useDataTableCsvExporter` prepares the filename, progress, cancellation, and fetch lifecycle for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It takes no column configuration and does not read DataTable context itself, so it can be called in the same parent component that creates the table, outside `DataTable.Root`. + +The hook and standalone [`useCsvExporter`](./use-csv-exporter.md) share the internal CSV processing. The difference is column ownership: `DataTable.CSVExporter` supplies the enclosing table's visible columns and order when a download starts. + +## Import + +```tsx +import { + DataTable, + useDataTable, + useDataTableCsvExporter, + type DataTableCSVExporterProps, + type UseDataTableCsvExporterOptions, +} from "@tailor-platform/app-shell"; +``` + +## Usage + +```tsx +const table = useDataTable({ columns, data, control }); +const { props } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + + + + +; +``` + +There is no duplicate `columns` argument. The component includes its own trigger button; the hook's `open()` action can also open the dialog from a caller-provided action. + +## Options + +| Option | Type | Description | +| ----------------- | ------------------------ | --------------------------------------------------------------------------------------------------- | +| `defaultFilename` | `string` | Initial filename displayed in the dialog. Required; `.csv` is added when omitted from the filename. | +| `fetcher` | `CsvCursorFetcher` | Fetches one cursor-paginated page of rows. Required. | +| `pageSize` | `number` | Rows requested per page. Defaults to `1000`. | + +`columns` is not accepted, including through a spread options object. The fetcher receives `{ first, after, signal }` and returns `{ rows, pageInfo, total? }`, just as for `useCsvExporter`. See its [fetcher example](./use-csv-exporter.md#usage) for adapting a GraphQL connection to rows and propagating cancellation when the data client supports it. + +Query execution and filter/sort normalization remain application-owned. Use the same query and normalized conditions for table loading and CSV fetching; neither this hook nor `DataTable.CSVExporter` automatically discovers or executes the table's query. + +## Column and lifecycle contract + +- The component derives the schema from the enclosing table's current visible columns and their order at download start, not when the dialog first opens. +- A labelled column needs an `accessor` or `id` to provide its raw value. Values come from `accessor(row)` first, then `row[id]`; rendered JSX is not serialized. Columns without a `label`, such as action columns, are excluded. +- Dates become ISO strings and primitive arrays are joined with `, `, using the same serialization as the standalone exporter. +- If no exportable columns remain, the export reports an error before fetching or downloading. +- Visibility and ordering changes during a running export affect the next export only; the in-flight schema stays fixed. +- The component supplies columns when executing the export. The hook alone does not start a download without that table schema. +- Cancellation and owner-unmount cleanup use the same shared implementation as `useCsvExporter`. + +## Return value + +| Property | Type | Description | +| -------- | --------------------------------- | --------------------------------------------------------------------------- | +| `open()` | `() => void` | Opens the exporter dialog. | +| `props` | `DataTableCSVExporterProps` | Spread onto `` inside `DataTable.Root`. | + +The `exporter` inside `props` carries a private type brand. `DataTable.CSVExporter` rejects standalone `useCsvExporter` props and rejects replacing the dedicated `exporter` with a standalone one. The brand is an API misuse guard, not a runtime security boundary against deliberate type assertions. + +For fixed report schemas or custom headers and value formatting, use `useCsvExporter` with standalone `CsvExporter` instead. diff --git a/docs/api/use-csv-exporter.md b/docs/api/use-csv-exporter.md index 653aea5e8..dbb84c887 100644 --- a/docs/api/use-csv-exporter.md +++ b/docs/api/use-csv-exporter.md @@ -7,7 +7,7 @@ description: Fetch all cursor-paginated rows and download them as a CSV file wit # useCsvExporter -`useCsvExporter` fetches every cursor-paginated page of rows, creates a UTF-8 CSV, and returns an exporter value for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It is client-agnostic: the calling application implements the `fetcher` with its own data client. +`useCsvExporter` fetches every cursor-paginated page of rows, creates a UTF-8 CSV, and returns props for the standalone [`CsvExporter`](../components/csv-exporter.md). It is client-agnostic: the calling application implements the `fetcher` with its own data client. ## Import @@ -23,7 +23,7 @@ import { ## Usage ```tsx -const { props } = useCsvExporter({ +const { open, props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher: async ({ first, after, signal }) => { @@ -49,7 +49,8 @@ const { props } = useCsvExporter({ }, }); -; + +; ``` The fetcher receives the cursor parameters for one page and returns a `CsvExportPage` shaped as `{ rows, pageInfo, total? }`, using the same row representation as DataTable. `pageInfo` must include `hasNextPage` and `endCursor`; `total` is optional. For a GraphQL connection, extract the nodes into `rows` in the fetcher, as shown above. The hook follows `pageInfo.endCursor` until `hasNextPage` is false, then serializes and downloads every fetched row. It passes an `AbortSignal`; pass it to the client request when the client supports cancellation. @@ -79,7 +80,7 @@ Without `total`, the progress dialog shows the number of rows fetched but not a | Option | Type | Description | | ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | | `defaultFilename` | `string` | Initial file name shown in the dialog. A `.csv` suffix is added when omitted. | -| `columns` | `CsvExportColumn[] \| Column[]` | Explicit CSV columns, or DataTable columns to derive them from. | +| `columns` | `CsvExportColumn[] \| Column[]` | Required. Explicit CSV columns, or DataTable columns to derive them from. | | `fetcher` | `CsvCursorFetcher` | Fetches one cursor page. Required. | | `pageSize` | `number` | Rows requested per page. Defaults to `1000`. | @@ -95,13 +96,13 @@ const exporter = useCsvExporter({ }); ``` -A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. When the exporter is passed to `DataTable.CSVExporter`, the current visible DataTable columns and their user-defined order are used. Use explicit `CsvExportColumn[]` definitions when CSV values need a different format from the table or must ignore user column settings. +A labelled DataTable column must provide `accessor` or `id`; otherwise TypeScript rejects it as a CSV column source. The exporter resolves values from `accessor(row)` first, then `row[id]`. Columns without a `label`, such as action columns, are excluded. Dates are exported as ISO strings and primitive arrays are joined with `, `. The supplied columns define the standalone schema; this hook does not read DataTable context or follow its visibility and ordering changes. Use explicit `CsvExportColumn[]` definitions for custom headers or value formatting. To export a table's current visible-column layout, use [`useDataTableCsvExporter`](./use-data-table-csv-exporter.md) with `DataTable.CSVExporter` instead; that component rejects standalone exporter props. ## Return value -| Property | Type | Description | -| -------- | ------------------------ | -------------------------------------------------------------------------------------------- | -| `open()` | `() => void` | Opens the standard exporter UI. | -| `props` | `CsvExporterProps` | Spread directly onto `` or ``. | +| Property | Type | Description | +| -------- | ------------------------ | ------------------------------------------------------------------------------------------- | +| `open()` | `() => void` | Opens the standard exporter UI. | +| `props` | `CsvExporterProps` | Spread directly onto ``. Not accepted by `DataTable.CSVExporter`. | CSV cells are escaped using RFC 4180-compatible quoting, UTF-8 BOM is included for spreadsheet compatibility, and formula-like cell values are escaped before download. diff --git a/docs/api/use-data-table-csv-exporter.md b/docs/api/use-data-table-csv-exporter.md new file mode 100644 index 000000000..26783c3ab --- /dev/null +++ b/docs/api/use-data-table-csv-exporter.md @@ -0,0 +1,74 @@ +--- +title: useDataTableCsvExporter +description: Prepare a CSV export whose columns come from the enclosing DataTable at download start +--- + + + +# useDataTableCsvExporter + +`useDataTableCsvExporter` prepares the filename, progress, cancellation, and fetch lifecycle for [`DataTable.CSVExporter`](../components/data-table.md#datatablecsvexporter). It takes no column configuration and does not read DataTable context itself, so it can be called in the same parent component that creates the table, outside `DataTable.Root`. + +The hook and standalone [`useCsvExporter`](./use-csv-exporter.md) share the internal CSV processing. The difference is column ownership: `DataTable.CSVExporter` supplies the enclosing table's visible columns and order when a download starts. + +## Import + +```tsx +import { + DataTable, + useDataTable, + useDataTableCsvExporter, + type DataTableCSVExporterProps, + type UseDataTableCsvExporterOptions, +} from "@tailor-platform/app-shell"; +``` + +## Usage + +```tsx +const table = useDataTable({ columns, data, control }); +const { props } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + + + + +; +``` + +There is no duplicate `columns` argument. The component includes its own trigger button; the hook's `open()` action can also open the dialog from a caller-provided action. + +## Options + +| Option | Type | Description | +| ----------------- | ------------------------ | --------------------------------------------------------------------------------------------------- | +| `defaultFilename` | `string` | Initial filename displayed in the dialog. Required; `.csv` is added when omitted from the filename. | +| `fetcher` | `CsvCursorFetcher` | Fetches one cursor-paginated page of rows. Required. | +| `pageSize` | `number` | Rows requested per page. Defaults to `1000`. | + +`columns` is not accepted, including through a spread options object. The fetcher receives `{ first, after, signal }` and returns `{ rows, pageInfo, total? }`, just as for `useCsvExporter`. See its [fetcher example](./use-csv-exporter.md#usage) for adapting a GraphQL connection to rows and propagating cancellation when the data client supports it. + +Query execution and filter/sort normalization remain application-owned. Use the same query and normalized conditions for table loading and CSV fetching; neither this hook nor `DataTable.CSVExporter` automatically discovers or executes the table's query. + +## Column and lifecycle contract + +- The component derives the schema from the enclosing table's current visible columns and their order at download start, not when the dialog first opens. +- A labelled column needs an `accessor` or `id` to provide its raw value. Values come from `accessor(row)` first, then `row[id]`; rendered JSX is not serialized. Columns without a `label`, such as action columns, are excluded. +- Dates become ISO strings and primitive arrays are joined with `, `, using the same serialization as the standalone exporter. +- If no exportable columns remain, the export reports an error before fetching or downloading. +- Visibility and ordering changes during a running export affect the next export only; the in-flight schema stays fixed. +- The component supplies columns when executing the export. The hook alone does not start a download without that table schema. +- Cancellation and owner-unmount cleanup use the same shared implementation as `useCsvExporter`. + +## Return value + +| Property | Type | Description | +| -------- | --------------------------------- | --------------------------------------------------------------------------- | +| `open()` | `() => void` | Opens the exporter dialog. | +| `props` | `DataTableCSVExporterProps` | Spread onto `` inside `DataTable.Root`. | + +The `exporter` inside `props` carries a private type brand. `DataTable.CSVExporter` rejects standalone `useCsvExporter` props and rejects replacing the dedicated `exporter` with a standalone one. The brand is an API misuse guard, not a runtime security boundary against deliberate type assertions. + +For fixed report schemas or custom headers and value formatting, use `useCsvExporter` with standalone `CsvExporter` instead. diff --git a/docs/components/csv-exporter.md b/docs/components/csv-exporter.md index 033326a53..8c6ae6fe6 100644 --- a/docs/components/csv-exporter.md +++ b/docs/components/csv-exporter.md @@ -45,10 +45,17 @@ The filename input is validated through AppShell `Form` and `Field`. While fetch ## DataTable -For an export coupled to a table, use [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter) with the same props. This variant includes its own trigger button: +For an export coupled to a table, use [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md) with [`DataTable.CSVExporter`](./data-table.md#datatablecsvexporter). The dedicated hook takes no `columns` option and can be called outside `DataTable.Root`: ```tsx - +const { props: tableExporter } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + + + +; ``` -When the hook is configured with DataTable columns, this variant exports only visible columns in their current user-defined order. +This variant includes its own trigger button and always exports the enclosing table's visible columns in their current order at download start. It accepts only the dedicated hook's branded exporter props, not the result of `useCsvExporter`. Use standalone `CsvExporter` for a fixed schema or custom report columns. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 64cb74a35..dcfe79e33 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -16,6 +16,7 @@ import { DataTable, useDataTable, useDataTableContext, + useDataTableCsvExporter, useCollectionVariables, createColumnHelper, type Column, @@ -155,7 +156,7 @@ function JournalsPage() { | `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | | `DataTable.Footer` | Footer container for pagination and other footer content. Optional. Becomes the bulk-action bar while rows are selected — see [Selection actions](#selection-actions). | | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | -| `DataTable.CSVExporter` | Standard CSV download button and filename/progress dialog. Pass an exporter from [`useCsvExporter`](../api/use-csv-exporter.md) through `exporter`. | +| `DataTable.CSVExporter` | CSV download button and filename/progress dialog. Pass the dedicated props from [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md). | ### `DataTable.Root` Props @@ -210,15 +211,22 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi ### `DataTable.CSVExporter` Props -| Prop | Type | Description | -| -------------- | ------------------------- | ----------------------------------------------------------------------------- | -| `open` | `boolean` | Whether the exporter dialog is open. | -| `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | -| `exporter` | `CsvExporterState` | Export state and actions from [`useCsvExporter`](../api/use-csv-exporter.md). | +| Prop | Type | Description | +| -------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `open` | `boolean` | Whether the exporter dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | Called when the dialog opens or closes. | +| `exporter` | `DataTableCSVExporterProps["exporter"]` | Branded export state from [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md). | -`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the fetcher returns `total`; it also provides a cancel action. When `useCsvExporter` receives DataTable columns, the current visible columns and their user-defined order are exported. Explicit `CsvExportColumn[]` definitions keep their own order and ignore column visibility. Compose the toolbar layout normally when it should sit immediately before the built-in Columns control: +`DataTable.CSVExporter` opens a dialog where the user can change the filename before downloading. During the export it shows the fetched row count and a percentage when the fetcher returns `total`; it also provides a cancel action. It accepts only props from `useDataTableCsvExporter`, whose `columns` option is intentionally unavailable. The hook can be called outside `DataTable.Root`; the component reads the enclosing table's visible columns and their order when the download starts. Hiding all exportable columns fails before fetching. Changes to visibility or order after an export starts apply to the next export, not the in-flight CSV. For a fixed schema or custom report columns, use standalone [`CsvExporter`](./csv-exporter.md) with `useCsvExporter` instead. + +Compose the toolbar layout normally inside `DataTable.Root` when the exporter should sit immediately before the built-in Columns control: ```tsx +const { props } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher, +}); + @@ -229,7 +237,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi - +; ``` ### `DataTable.Pagination` Props diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index f3f708be6..2a2a4187e 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx @@ -5,7 +5,7 @@ import { DataTable, Toolbar, useDataTable, - useCsvExporter, + useDataTableCsvExporter, useCollectionVariables, createColumnHelper, type AppShellPageProps, @@ -520,9 +520,8 @@ const DataTableLabPage = () => { rowActions, }); - const { props: exporter } = useCsvExporter({ + const { props: exporter } = useDataTableCsvExporter({ defaultFilename: "invoices.csv", - columns: baseColumns, fetcher: async () => ({ rows, pageInfo: { hasNextPage: false, endCursor: null }, diff --git a/examples/vite-app/src/pages/showcase/data-table/page.tsx b/examples/vite-app/src/pages/showcase/data-table/page.tsx index 77e8eb316..1fd96afc5 100644 --- a/examples/vite-app/src/pages/showcase/data-table/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table/page.tsx @@ -6,7 +6,7 @@ import { Tabs, Toolbar, useDataTable, - useCsvExporter, + useDataTableCsvExporter, useCollectionVariables, createColumnHelper, type CollectionControl, @@ -342,9 +342,8 @@ function InvoiceTable({ }, [variables, variablesKey]); const table = useDataTable({ columns, data, loading, control }); - const { props: exporter } = useCsvExporter({ + const { props: exporter } = useDataTableCsvExporter({ defaultFilename: "invoices.csv", - columns, pageSize: 10, fetcher: ({ first, after }) => queryInvoices({ diff --git a/packages/core/src/components/csv-exporter/csv-exporter.tsx b/packages/core/src/components/csv-exporter/csv-exporter.tsx index 43bb6f26f..cac94185f 100644 --- a/packages/core/src/components/csv-exporter/csv-exporter.tsx +++ b/packages/core/src/components/csv-exporter/csv-exporter.tsx @@ -6,20 +6,14 @@ import { Field } from "@/components/field"; import { Form } from "@/components/form"; import { Spinner } from "@/components/spinner"; import { useCsvExporterT } from "@/components/csv-exporter/i18n"; -import type { Column } from "@/components/data-table/types"; import type { CsvExporterProps } from "./types"; -type CsvExporterViewProps> = CsvExporterProps & { - columnOverride?: readonly Column[]; -}; - -/** @internal Shared CSV export UI. DataTable supplies its visible columns here. */ +/** @internal Shared filename dialog and progress UI. */ export function CsvExporterView>({ open, onOpenChange, exporter, - columnOverride, -}: CsvExporterViewProps) { +}: CsvExporterProps) { const t = useCsvExporterT(); const formId = useId(); const [filename, setFilename] = useState(exporter.defaultFilename); @@ -33,7 +27,7 @@ export function CsvExporterView>({ }; const startExport = async ({ filename: exportFilename }: { filename: string }) => { - if (await exporter.exportCsv(exportFilename, columnOverride)) onOpenChange(false); + if (await exporter.exportCsv(exportFilename)) onOpenChange(false); }; return ( @@ -123,8 +117,8 @@ export function CsvExporterView>({ * ; * ``` * - * Use `DataTable.CSVExporter` instead when the export should reconcile the - * current DataTable column layout. + * Use `useDataTableCsvExporter` with `DataTable.CSVExporter` when the export + * should follow the current DataTable column layout. */ export function CsvExporter>(props: CsvExporterProps) { return ; diff --git a/packages/core/src/components/csv-exporter/types.ts b/packages/core/src/components/csv-exporter/types.ts index f891e8674..8a324ec3d 100644 --- a/packages/core/src/components/csv-exporter/types.ts +++ b/packages/core/src/components/csv-exporter/types.ts @@ -77,8 +77,8 @@ export type CsvExportPhase = /** * Configuration for `useCsvExporter`. * - * Pass DataTable columns to make `DataTable.CSVExporter` honor the user's - * visible-column layout, or pass `CsvExportColumn[]` for a fixed report schema. + * Supply explicit CSV columns or DataTable column definitions as a configured + * schema. Use `useDataTableCsvExporter` for the table's current visible layout. */ export interface UseCsvExporterOptions> { /** Initial filename displayed in the export dialog. The .csv suffix is added when omitted. */ @@ -94,9 +94,8 @@ export interface UseCsvExporterOptions> { /** * State and actions returned inside `useCsvExporter`'s `props` value. * - * `CsvExporter` and `DataTable.CSVExporter` receive this value through their - * `exporter` prop. Call `exportCsv()` from a - * custom button when no DataTable is involved. + * Standalone `CsvExporter` receives this value through its `exporter` prop. + * Call `exportCsv()` from a custom button to export using the configured schema. * * @example * ```tsx @@ -123,8 +122,8 @@ export interface CsvExporterState = Record< * Props for `CsvExporter`, managed by `useCsvExporter`. * * Spread the hook's `props` return value directly onto the component. - * `DataTable.CSVExporter` accepts the same props and additionally reconciles - * DataTable's visible-column layout. + * `DataTable.CSVExporter` requires dedicated props from `useDataTableCsvExporter` + * instead, so its schema always comes from the table. */ export interface CsvExporterProps = Record> { open: boolean; diff --git a/packages/core/src/components/csv-exporter/use-csv-export-controller.ts b/packages/core/src/components/csv-exporter/use-csv-export-controller.ts new file mode 100644 index 000000000..fdfb6d60b --- /dev/null +++ b/packages/core/src/components/csv-exporter/use-csv-export-controller.ts @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { unparse } from "papaparse"; +import { getCellValue } from "@/components/data-table/cell-renderers"; +import type { Column } from "@/components/data-table/types"; +import { useToast } from "@/hooks/use-toast"; +import { useCsvExporterT } from "./i18n"; +import type { + CsvExportCell, + CsvExportColumn, + CsvExporterState, + CsvExporterProps, + UseCsvExporterOptions, +} from "./types"; + +const DEFAULT_PAGE_SIZE = 1000; + +function toCsvCell(value: unknown): CsvExportCell { + if ( + value == null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return value; + } + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); + if (Array.isArray(value)) + return value + .map(toCsvCell) + .filter((item) => item != null) + .join(", "); + return undefined; +} + +function isCsvExportColumn>( + column: CsvExportColumn | Column, +): column is CsvExportColumn { + return "value" in column && typeof column.value === "function"; +} + +function resolveColumns>( + columns: readonly CsvExportColumn[] | readonly Column[], +): CsvExportColumn[] { + if (columns.length === 0 || isCsvExportColumn(columns[0])) { + return columns as CsvExportColumn[]; + } + + return (columns as readonly Column[]).flatMap((column) => { + if (!column.label) return []; + if (!column.id && !column.accessor) { + throw new Error(`Column "${column.label}" requires an id or accessor for CSV export`); + } + return { + header: column.label, + value: (row: TRow) => { + return toCsvCell(getCellValue(row, column)); + }, + }; + }); +} + +function normalizeFilename(filename: string): string { + const normalized = filename.trim().replace(/[\\/]/g, "_"); + if (!normalized) throw new Error("A CSV filename is required"); + return normalized.toLowerCase().endsWith(".csv") ? normalized : `${normalized}.csv`; +} + +function downloadCsv(csv: string, filename: string) { + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +/** @internal Shared export lifecycle; table columns are supplied when export starts. */ +export function useCsvExportController>( + { + defaultFilename, + fetcher, + pageSize = DEFAULT_PAGE_SIZE, + }: Omit, "columns">, + columnSource?: UseCsvExporterOptions["columns"], +) { + const toast = useToast(); + const t = useCsvExporterT(); + const [isOpen, setIsOpen] = useState(false); + const open = useCallback(() => { + setIsOpen(true); + }, []); + const abortControllerRef = useRef(null); + useEffect( + () => () => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }, + [], + ); + const [phase, setPhase] = useState("idle"); + const [progress, setProgress] = useState({ + completed: 0, + total: null, + }); + const [error, setError] = useState(null); + + const cancel = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); + + const exportCsv = useCallback( + async ( + requestedFilename = defaultFilename, + columnOverride?: readonly CsvExportColumn[] | readonly Column[], + ) => { + if (abortControllerRef.current) return false; + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + setPhase("fetching"); + setProgress({ completed: 0, total: null }); + setError(null); + + try { + const filename = normalizeFilename(requestedFilename); + const source = columnOverride ?? columnSource; + const columns = source ? resolveColumns(source) : []; + if (columns.length === 0) { + const exportError = new Error(t("noColumns")); + setError(exportError); + setPhase("error"); + toast.error(exportError.message); + return false; + } + const rows: TRow[] = []; + const seenCursors = new Set(); + let after: string | null = null; + let total: number | null = null; + + for (;;) { + const page = await fetcher({ + first: pageSize, + after, + signal: abortController.signal, + }); + if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); + if (!page) break; + + rows.push(...page.rows); + total ??= page.total ?? null; + setProgress({ completed: rows.length, total }); + + if (!page.pageInfo.hasNextPage) break; + const next = page.pageInfo.endCursor; + if (next == null || seenCursors.has(next)) { + throw new Error("CSV export pagination did not advance"); + } + seenCursors.add(next); + after = next; + } + + if (rows.length === 0) { + setPhase("success"); + toast.info(t("noRows")); + return true; + } + + setPhase("serializing"); + const csv = unparse( + { + fields: columns.map((column) => column.header), + data: rows.map((row) => columns.map((column) => column.value(row) ?? "")), + }, + { newline: "\r\n", escapeFormulae: true }, + ); + + if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); + setPhase("downloading"); + downloadCsv(csv, filename); + setPhase("success"); + toast.success(t("exportComplete", { count: rows.length })); + return true; + } catch (caught) { + if (abortControllerRef.current !== abortController) return false; + if ( + abortController.signal.aborted || + (caught instanceof DOMException && caught.name === "AbortError") + ) { + setPhase("cancelled"); + toast.info(t("exportCancelled")); + } else { + const exportError = caught instanceof Error ? caught : new Error(String(caught)); + setError(exportError); + setPhase("error"); + toast.error(t("exportFailed")); + } + return false; + } finally { + abortControllerRef.current = null; + } + }, + [columnSource, defaultFilename, fetcher, pageSize, t, toast], + ); + + const exporter: CsvExporterState = { + defaultFilename, + exportCsv, + cancel, + phase, + progress, + error, + }; + const props: CsvExporterProps = { open: isOpen, onOpenChange: setIsOpen, exporter }; + + return { open, props }; +} diff --git a/packages/core/src/components/csv-exporter/use-csv-exporter.ts b/packages/core/src/components/csv-exporter/use-csv-exporter.ts index 6d55f24ed..dc450550c 100644 --- a/packages/core/src/components/csv-exporter/use-csv-exporter.ts +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -1,241 +1,31 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { unparse } from "papaparse"; -import { getCellValue } from "@/components/data-table/cell-renderers"; -import type { Column } from "@/components/data-table/types"; -import { useToast } from "@/hooks/use-toast"; -import { useCsvExporterT } from "./i18n"; -import type { - CsvExportPage, - CsvExportCell, - CsvExportColumn, - CsvExporterState, - CsvExporterProps, - UseCsvExporterOptions, -} from "./types"; - -const DEFAULT_PAGE_SIZE = 1000; - -function toCsvCell(value: unknown): CsvExportCell { - if ( - value == null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return value; - } - if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); - if (Array.isArray(value)) - return value - .map(toCsvCell) - .filter((item) => item != null) - .join(", "); - return undefined; -} - -function isCsvExportColumn>( - column: CsvExportColumn | Column, -): column is CsvExportColumn { - return "value" in column && typeof column.value === "function"; -} - -function resolveColumns>( - columns: readonly CsvExportColumn[] | readonly Column[], -): CsvExportColumn[] { - if (columns.length === 0 || isCsvExportColumn(columns[0])) { - return columns as CsvExportColumn[]; - } - - return (columns as readonly Column[]).flatMap((column) => { - if (!column.label) return []; - return { - header: column.label, - value: (row: TRow) => { - return toCsvCell(getCellValue(row, column)); - }, - }; - }); -} - -function normalizeFilename(filename: string): string { - const normalized = filename.trim().replace(/[\\/]/g, "_"); - if (!normalized) throw new Error("A CSV filename is required"); - return normalized.toLowerCase().endsWith(".csv") ? normalized : `${normalized}.csv`; -} - -function downloadCsv(csv: string, filename: string) { - const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); -} +import { useCsvExportController } from "./use-csv-export-controller"; +import type { UseCsvExporterOptions } from "./types"; /** - * Fetch every cursor page, serialize its rows as CSV, and expose progress and - * cancellation actions. The data client stays outside this hook: `fetcher` - * only needs to return a cursor page with `rows` and `pageInfo`. + * Fetch cursor-paginated rows and export them using the supplied column schema. + * Returns controlled props for the standalone `CsvExporter` and an open action. * * @example * ```tsx - * const { props } = useCsvExporter({ + * const { open, props } = useCsvExporter({ * defaultFilename: "products.csv", * columns, - * fetcher: async ({ first, after, signal }) => { - * return client.listProducts({ first, after, signal }); - * }, + * fetcher: ({ first, after, signal }) => client.listProducts({ first, after, signal }), * }); * - * ; + * + * ; * ``` * - * When `columns` is a DataTable column array, `DataTable.CSVExporter` exports - * its current visible columns in the user's selected order. Explicit - * `CsvExportColumn[]` definitions remain a fixed report schema instead. + * DataTable column definitions can be used as a configured schema, but do not + * follow a table's visibility or order. Use `useDataTableCsvExporter` with + * `DataTable.CSVExporter` for that integration. */ export function useCsvExporter>({ - defaultFilename, - columns: columnSource, - fetcher, - pageSize = DEFAULT_PAGE_SIZE, + columns, + ...options }: UseCsvExporterOptions) { - const toast = useToast(); - const t = useCsvExporterT(); - const usesDataTableColumns = columnSource.length > 0 && !isCsvExportColumn(columnSource[0]); - const [isOpen, setIsOpen] = useState(false); - const open = useCallback(() => { - setIsOpen(true); - }, []); - const abortControllerRef = useRef(null); - useEffect( - () => () => { - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - }, - [], - ); - const [phase, setPhase] = useState("idle"); - const [progress, setProgress] = useState({ - completed: 0, - total: null, - }); - const [error, setError] = useState(null); - - const cancel = useCallback(() => { - abortControllerRef.current?.abort(); - }, []); - - const exportCsv = useCallback( - async ( - requestedFilename = defaultFilename, - columnOverride?: readonly CsvExportColumn[] | readonly Column[], - ) => { - if (abortControllerRef.current) return false; - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - setPhase("fetching"); - setProgress({ completed: 0, total: null }); - setError(null); - - try { - const filename = normalizeFilename(requestedFilename); - const columns = resolveColumns( - columnOverride && usesDataTableColumns ? columnOverride : columnSource, - ); - if (usesDataTableColumns && columns.length === 0) { - const exportError = new Error(t("noColumns")); - setError(exportError); - setPhase("error"); - toast.error(exportError.message); - return false; - } - const rows: TRow[] = []; - const seenCursors = new Set(); - let after: string | null = null; - let total: number | null = null; - - for (;;) { - const page = await fetcher({ - first: pageSize, - after, - signal: abortController.signal, - }); - if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); - if (!page) break; - - rows.push(...page.rows); - total ??= page.total ?? null; - setProgress({ completed: rows.length, total }); - - if (!page.pageInfo.hasNextPage) break; - const next = page.pageInfo.endCursor; - if (next == null || seenCursors.has(next)) { - throw new Error("CSV export pagination did not advance"); - } - seenCursors.add(next); - after = next; - } - - if (rows.length === 0) { - setPhase("success"); - toast.info(t("noRows")); - return true; - } - - setPhase("serializing"); - const csv = unparse( - { - fields: columns.map((column) => column.header), - data: rows.map((row) => columns.map((column) => column.value(row) ?? "")), - }, - { newline: "\r\n", escapeFormulae: true }, - ); - - if (abortController.signal.aborted) throw new DOMException("Aborted", "AbortError"); - setPhase("downloading"); - downloadCsv(csv, filename); - setPhase("success"); - toast.success(t("exportComplete", { count: rows.length })); - return true; - } catch (caught) { - if (abortControllerRef.current !== abortController) return false; - if ( - abortController.signal.aborted || - (caught instanceof DOMException && caught.name === "AbortError") - ) { - setPhase("cancelled"); - toast.info(t("exportCancelled")); - } else { - const exportError = caught instanceof Error ? caught : new Error(String(caught)); - setError(exportError); - setPhase("error"); - toast.error(t("exportFailed")); - } - return false; - } finally { - abortControllerRef.current = null; - } - }, - [columnSource, defaultFilename, fetcher, pageSize, t, toast, usesDataTableColumns], - ); - - const exporter: CsvExporterState = { - defaultFilename, - exportCsv, - cancel, - phase, - progress, - error, - }; - const props: CsvExporterProps = { open: isOpen, onOpenChange: setIsOpen, exporter }; - - return { open, props }; + return useCsvExportController(options, columns); } -export type { CsvExportPage }; +export type { CsvExportPage } from "./types"; diff --git a/packages/core/src/components/data-table/csv-exporter.test.tsx b/packages/core/src/components/data-table/csv-exporter.test.tsx index 651089d42..6ea8fd54e 100644 --- a/packages/core/src/components/data-table/csv-exporter.test.tsx +++ b/packages/core/src/components/data-table/csv-exporter.test.tsx @@ -1,58 +1,63 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { DataTable } from "./data-table"; import { DataTableCSVExporter } from "./csv-exporter"; import { useDataTable } from "./use-data-table"; -import type { CsvExporterState } from "@/components/csv-exporter"; +import { useDataTableCsvExporter } from "./use-data-table-csv-exporter"; +import type { DataTableCSVExporterProps } from "./use-data-table-csv-exporter"; +import type { Column } from "./types"; +import type { CsvCursorFetcher, CsvExportPage } from "@/components/csv-exporter"; const wrapper = createAppShellWrapper("en"); -type Row = { name: string }; - -function TestCSVExporter({ exporter }: { exporter: CsvExporterState }) { - const [open, setOpen] = useState(false); - const table = useDataTable({ - columns: [ - { id: "name", label: "Name", render: (row) => row.name }, - { id: "hidden", label: "Hidden", render: () => "hidden" }, - ], - data: { rows: [] }, - }); - - const { toggleColumn } = table; - useEffect(() => { - toggleColumn("hidden"); - }, [toggleColumn]); +type Row = { name: string; sku: string }; +const columns: Column[] = [ + { id: "name", label: "Name", render: (row) => row.name }, + { id: "sku", label: "SKU", render: (row) => row.sku }, +]; +const page: CsvExportPage = { + rows: [{ name: "Alice", sku: "A-1" }], + pageInfo: { hasNextPage: false, endCursor: null }, + total: 1, +}; +const emptyFetcher: CsvCursorFetcher = async () => null; + +function TestCSVExporter({ + fetcher = emptyFetcher, + tableColumns = columns, + exporter, +}: { + fetcher?: CsvCursorFetcher; + tableColumns?: Column[]; + exporter?: Partial["exporter"]>; +}) { + const table = useDataTable({ columns: tableColumns, data: { rows: [] } }); + const { props } = useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher }); return ( - + + + + ); } afterEach(() => { cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("DataTable.CSVExporter", () => { - const idleExporter: CsvExporterState = { - defaultFilename: "products.csv", - exportCsv: vi.fn().mockResolvedValue(true), - cancel: vi.fn(), - phase: "idle", - progress: { completed: 0, total: null }, - error: null, - }; - it("reports indeterminate and determinate export progress", async () => { const user = userEvent.setup(); - const { rerender } = render( - , - { wrapper }, - ); + const { rerender } = render(, { + wrapper, + }); await user.click(screen.getByRole("button", { name: "CSV Export" })); expect( @@ -62,9 +67,7 @@ describe("DataTable.CSVExporter", () => { ).toBe(true); rerender( - , + , ); const progress = screen.getByRole("progressbar", { name: "1 / 2 rows fetched" }); @@ -74,29 +77,22 @@ describe("DataTable.CSVExporter", () => { it("can reopen an in-progress export dialog", async () => { const user = userEvent.setup(); - const exporter: CsvExporterState = { ...idleExporter, phase: "fetching" }; - - render(, { wrapper }); + render(, { wrapper }); await user.click(screen.getByRole("button", { name: "CSV Export" })); expect(screen.getByRole("dialog")).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Close" })); expect(screen.queryByRole("dialog")).toBeNull(); - await user.click(screen.getByRole("button", { name: "CSV Export" })); expect(screen.getByRole("dialog")).toBeDefined(); }); - it("uses the dialog filename and visible columns when starting an export", async () => { + it("uses the dialog filename and current visible columns when starting an export", async () => { const user = userEvent.setup(); - const exporter: CsvExporterState = { - ...idleExporter, - exportCsv: vi.fn().mockResolvedValue(true), - }; - - render(, { wrapper }); + const exportCsv = vi.fn().mockResolvedValue(true); + render(, { wrapper }); + await user.click(screen.getByRole("button", { name: "Toggle SKU" })); await user.click(screen.getByRole("button", { name: "CSV Export" })); const filename = screen.getByLabelText("File name"); await user.clear(filename); @@ -104,10 +100,115 @@ describe("DataTable.CSVExporter", () => { await user.click(screen.getByRole("button", { name: "Download" })); await waitFor(() => { - expect(exporter.exportCsv).toHaveBeenCalled(); + expect(exportCsv).toHaveBeenCalledWith("all-products", [ + expect.objectContaining({ id: "name" }), + ]); + }); + }); + + it("exports columns that are shown again, in their current table order", async () => { + const user = userEvent.setup(); + const fetcher = vi.fn>().mockResolvedValue(page); + const downloaded: Blob[] = []; + vi.stubGlobal("URL", { + createObjectURL: vi.fn((blob: Blob) => { + downloaded.push(blob); + return "blob:csv"; + }), + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "Toggle SKU" })); + await user.click(screen.getByRole("button", { name: "Toggle SKU" })); + await user.click(screen.getByRole("button", { name: "SKU first" })); + await user.click(screen.getByRole("button", { name: "CSV Export" })); + await user.click(screen.getByRole("button", { name: "Download" })); + + await waitFor(() => expect(downloaded).toHaveLength(1)); + expect(await downloaded[0]!.text()).toBe("\uFEFFSKU,Name\r\nA-1,Alice"); + }); + + it("rejects hiding every column before fetching or downloading", async () => { + const user = userEvent.setup(); + const fetcher = vi.fn>(); + const errorToast = vi.spyOn(toast, "error").mockReturnValue("toast"); + const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "Hide all" })); + await user.click(screen.getByRole("button", { name: "CSV Export" })); + await user.click(screen.getByRole("button", { name: "Download" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toBe("Select at least one column to export"); }); - expect(exporter.exportCsv).toHaveBeenCalledWith("all-products", [ - expect.objectContaining({ id: "name" }), - ]); + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Select at least one column to export"); + expect(fetcher).not.toHaveBeenCalled(); + expect(download).not.toHaveBeenCalled(); + }); + + it("requires an id or accessor for labelled table columns before fetching", async () => { + const user = userEvent.setup(); + const fetcher = vi.fn>(); + vi.spyOn(toast, "error").mockReturnValue("toast"); + render( + row.name }]} + />, + { wrapper }, + ); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + await user.click(screen.getByRole("button", { name: "Download" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toBe( + 'Column "Name" requires an id or accessor for CSV export', + ); + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("keeps an in-flight schema fixed and uses updated visibility on the next export", async () => { + const user = userEvent.setup(); + let resolveFetch!: (value: CsvExportPage) => void; + const fetcher = vi + .fn>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ) + .mockResolvedValueOnce(page); + const downloaded: Blob[] = []; + vi.stubGlobal("URL", { + createObjectURL: vi.fn((blob: Blob) => { + downloaded.push(blob); + return "blob:csv"; + }), + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + await user.click(screen.getByRole("button", { name: "Download" })); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Close" })); + await user.click(screen.getByRole("button", { name: "Toggle SKU" })); + await user.click(screen.getByRole("button", { name: "SKU first" })); + await act(async () => resolveFetch(page)); + + await waitFor(() => expect(downloaded).toHaveLength(1)); + expect(await downloaded[0]!.text()).toBe("\uFEFFName,SKU\r\nAlice,A-1"); + + await user.click(screen.getByRole("button", { name: "CSV Export" })); + await user.click(screen.getByRole("button", { name: "Download" })); + await waitFor(() => expect(downloaded).toHaveLength(2)); + expect(await downloaded[1]!.text()).toBe("\uFEFFName\r\nAlice"); }); }); diff --git a/packages/core/src/components/data-table/csv-exporter.tsx b/packages/core/src/components/data-table/csv-exporter.tsx index 6c6301c05..f71bbd4ba 100644 --- a/packages/core/src/components/data-table/csv-exporter.tsx +++ b/packages/core/src/components/data-table/csv-exporter.tsx @@ -2,19 +2,18 @@ import { Download } from "lucide-react"; import { Button } from "@/components/button"; import { CsvExporterView } from "@/components/csv-exporter/csv-exporter"; import { useCsvExporterT } from "@/components/csv-exporter/i18n"; -import type { CsvExporterProps } from "@/components/csv-exporter"; +import type { DataTableCSVExporterProps } from "./use-data-table-csv-exporter"; import { useDataTableContext } from "./data-table-context"; /** * CSV exporter integrated with the enclosing DataTable. * - * Its UI and lifecycle are identical to `CsvExporter`; the only addition is - * that the table's current visible columns and order become the CSV schema when - * `useCsvExporter` was configured with DataTable columns. + * Accepts only props from `useDataTableCsvExporter`. The table's current visible + * columns and order become the CSV schema when the download starts. * * @example * ```tsx - * const { props } = useCsvExporter({ defaultFilename: "products.csv", columns, fetcher }); + * const { props } = useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher }); * * * @@ -22,7 +21,7 @@ import { useDataTableContext } from "./data-table-context"; * ``` */ export function DataTableCSVExporter>( - props: CsvExporterProps, + props: DataTableCSVExporterProps, ) { const { visibleColumns } = useDataTableContext(); const t = useCsvExporterT(); @@ -35,10 +34,16 @@ export function DataTableCSVExporter>( {t("csvExporter")}
- + + props.exporter.exportCsv(filename, visibleColumns), + }} + /> ); } -export type DataTableCSVExporterProps> = - CsvExporterProps; +export type { DataTableCSVExporterProps } from "./use-data-table-csv-exporter"; diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index 7fb31d5bf..60c0e77a7 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -3,6 +3,10 @@ export { DataTable, type DataTablePaginationProps, type DataTableRootProps } fro export { type DataTableCSVExporterProps } from "./csv-exporter"; export { useDataTable } from "./use-data-table"; +export { + useDataTableCsvExporter, + type UseDataTableCsvExporterOptions, +} from "./use-data-table-csv-exporter"; export { useDataTableContext, type DataTableContextValue } from "./data-table-context"; // Field helpers diff --git a/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx b/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx new file mode 100644 index 000000000..a4e879685 --- /dev/null +++ b/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { toast } from "sonner"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { useCsvExporter } from "@/components/csv-exporter"; +import { DataTable } from "./data-table"; +import { useDataTableCsvExporter } from "./use-data-table-csv-exporter"; +import type { UseDataTableCsvExporterOptions } from "./use-data-table-csv-exporter"; +import type { + CsvCursorFetcher, + CsvExportPage, + UseCsvExporterOptions, +} from "@/components/csv-exporter"; +import type { DataTableCSVExporterProps } from "@/index"; + +const wrapper = createAppShellWrapper("en"); +type Row = { name: string }; +const fetcher: CsvCursorFetcher = async () => null; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("useDataTableCsvExporter", () => { + it("returns controlled props and an open action without DataTable context", () => { + const { result } = renderHook( + () => useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher }), + { wrapper }, + ); + + expect(result.current.props.open).toBe(false); + expect(result.current.props.exporter.phase).toBe("idle"); + act(() => result.current.open()); + expect(result.current.props.open).toBe(true); + act(() => result.current.props.onOpenChange(false)); + expect(result.current.props.open).toBe(false); + }); + + it("rejects column configuration, including options from a standalone hook", () => { + const standalone: UseCsvExporterOptions = { + defaultFilename: "products.csv", + fetcher, + columns: [{ header: "Name", value: (row) => row.name }], + }; + const invalid: UseDataTableCsvExporterOptions = { + defaultFilename: "products.csv", + fetcher, + // @ts-expect-error Table export columns are supplied by the component, not the hook. + columns: [], + }; + // @ts-expect-error Columns must also be rejected when options are passed through a variable. + const invalidSpread: UseDataTableCsvExporterOptions = { ...standalone }; + + expect(invalid).toBeDefined(); + expect(invalidSpread).toBeDefined(); + }); + + it("accepts only the dedicated exporter, even when controlled props are recomposed", () => { + const { result } = renderHook( + () => ({ + table: useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher }), + standalone: useCsvExporter({ + defaultFilename: "report.csv", + fetcher, + columns: [{ header: "Name", value: (row) => row.name }], + }), + }), + { wrapper }, + ); + const tableProps: DataTableCSVExporterProps = result.current.table.props; + const valid = ; + // @ts-expect-error A standalone exporter does not carry the private table brand. + const invalid = ; + const substituted: DataTableCSVExporterProps = { + ...tableProps, + // @ts-expect-error Keeping table props cannot make a standalone exporter valid. + exporter: result.current.standalone.props.exporter, + }; + const startWithoutColumns = () => { + // @ts-expect-error A table export requires the columns supplied by DataTable.CSVExporter. + return tableProps.exporter.exportCsv(); + }; + + expect(valid).toBeDefined(); + expect(invalid).toBeDefined(); + expect(substituted).toBeDefined(); + expect(startWithoutColumns).toBeTypeOf("function"); + }); + + it("cancels an in-flight table export", async () => { + const pendingFetcher = vi.fn>( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }), + ); + vi.spyOn(toast, "info").mockReturnValue("toast"); + const { result } = renderHook( + () => useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher: pendingFetcher }), + { wrapper }, + ); + let exportPromise!: Promise; + act(() => { + exportPromise = result.current.props.exporter.exportCsv("products.csv", [ + { id: "name", label: "Name", render: (row) => row.name }, + ]); + }); + act(() => result.current.props.exporter.cancel()); + await act(async () => { + await expect(exportPromise).resolves.toBe(false); + }); + + expect(result.current.props.exporter.phase).toBe("cancelled"); + }); + + it.each(["resolves", "rejects"])( + "aborts on unmount without side effects when the pending fetch later %s", + async (outcome) => { + let resolveFetch!: (page: CsvExportPage) => void; + let rejectFetch!: (error: Error) => void; + let signal!: AbortSignal; + const pendingFetcher = vi.fn>(({ signal: nextSignal }) => { + signal = nextSignal; + return new Promise((resolve, reject) => { + resolveFetch = resolve; + rejectFetch = reject; + }); + }); + const errorToast = vi.spyOn(toast, "error").mockReturnValue("toast"); + const infoToast = vi.spyOn(toast, "info").mockReturnValue("toast"); + const successToast = vi.spyOn(toast, "success").mockReturnValue("toast"); + const download = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + const { result, unmount } = renderHook( + () => useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher: pendingFetcher }), + { wrapper }, + ); + let exportPromise!: Promise; + act(() => { + exportPromise = result.current.props.exporter.exportCsv("products.csv", [ + { id: "name", label: "Name", render: (row) => row.name }, + ]); + }); + unmount(); + expect(signal.aborted).toBe(true); + if (outcome === "resolves") { + resolveFetch({ + rows: [{ name: "Alice" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + } else { + rejectFetch(new Error("Fetch failed after unmount")); + } + + await expect(exportPromise).resolves.toBe(false); + expect(pendingFetcher).toHaveBeenCalledTimes(1); + expect(download).not.toHaveBeenCalled(); + expect(errorToast).not.toHaveBeenCalled(); + expect(infoToast).not.toHaveBeenCalled(); + expect(successToast).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/core/src/components/data-table/use-data-table-csv-exporter.ts b/packages/core/src/components/data-table/use-data-table-csv-exporter.ts new file mode 100644 index 000000000..23b655eb8 --- /dev/null +++ b/packages/core/src/components/data-table/use-data-table-csv-exporter.ts @@ -0,0 +1,52 @@ +import { useCsvExportController } from "@/components/csv-exporter/use-csv-export-controller"; +import type { + CsvExporterProps, + CsvExporterState, + UseCsvExporterOptions, +} from "@/components/csv-exporter/types"; +import type { Column } from "./types"; + +const dataTableCsvExporterBrand = Symbol("DataTableCsvExporter"); + +/** Options for a table export; its columns come from the enclosing DataTable. */ +export type UseDataTableCsvExporterOptions> = Omit< + UseCsvExporterOptions, + "columns" +> & { columns?: never }; + +/** Controlled props produced by `useDataTableCsvExporter` for `DataTable.CSVExporter`. */ +export type DataTableCSVExporterProps> = Omit< + CsvExporterProps, + "exporter" +> & { + exporter: Omit, "exportCsv"> & { + readonly [dataTableCsvExporterBrand]: true; + exportCsv: (filename: string, columns: readonly Column[]) => Promise; + }; +}; + +/** + * Prepare a DataTable CSV export without configuring columns or reading context. + * `DataTable.CSVExporter` supplies its current visible columns when export starts. + * This hook can be called outside `DataTable.Root`. + * + * @example + * ```tsx + * const { props } = useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher }); + * + * + * + * ; + * ``` + */ +export function useDataTableCsvExporter>( + options: UseDataTableCsvExporterOptions, +) { + const { open, props } = useCsvExportController(options); + const tableProps: DataTableCSVExporterProps = { + ...props, + exporter: { ...props.exporter, [dataTableCsvExporterBrand]: true }, + }; + + return { open, props: tableProps }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 71a857200..a91fc8739 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -305,6 +305,7 @@ export { export { DataTable, useDataTable, + useDataTableCsvExporter, useDataTableContext, createColumnHelper, type DataTablePaginationProps, @@ -322,6 +323,7 @@ export { type MetadataFieldOptions, type DataTableContextValue, type DataTableCSVExporterProps, + type UseDataTableCsvExporterOptions, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; export { useURLCollectionVariables, withURLCollectionState } from "./lib/collection-url-state"; From f10be885ed016863445f98862948a2c5010cf072 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 14 Oct 2026 14:27:19 +0900 Subject: [PATCH 14/14] test(data-table): cover CSV criteria across renders --- docs-manifest.json | 6 +- ...se-data-table-csv-exporter.docs.outline.md | 62 +++++++++++ docs/api/use-data-table-csv-exporter.md | 62 +++++++++++ .../use-data-table-csv-exporter.test.tsx | 100 +++++++++++++++++- 4 files changed, 226 insertions(+), 4 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index f0bf260e4..bb9b5227a 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1573,9 +1573,9 @@ ], "hashes": { "typeSurface": "ae941a345a689328", - "outline": "065d8c99523aade8", + "outline": "c582da24e5f09fad", "snapshot": null, - "outputMd": "843f40c6595c82c8", + "outputMd": "df7e58ddf9cc4cc2", "examples": null } }, @@ -1804,7 +1804,7 @@ "packages/core/skills/app-shell-patterns/references/api/use-app-shell-data.md": "2d2eaf5aa04d95d3", "packages/core/skills/app-shell-patterns/references/api/use-auth.md": "a7d907ac79d24944", "packages/core/skills/app-shell-patterns/references/api/use-csv-exporter.md": "b6fc07903fdca468", - "packages/core/skills/app-shell-patterns/references/api/use-data-table-csv-exporter.md": "843f40c6595c82c8", + "packages/core/skills/app-shell-patterns/references/api/use-data-table-csv-exporter.md": "df7e58ddf9cc4cc2", "packages/core/skills/app-shell-patterns/references/api/use-override-breadcrumb.md": "6ec55fc3dfd8c21d", "packages/core/skills/app-shell-patterns/references/api/use-page-meta.md": "f988bdc0fb180f0c", "packages/core/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md": "7144ae3dc68aa65b", diff --git a/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md b/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md index 9f78887bc..65e1fe842 100644 --- a/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md +++ b/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md @@ -20,6 +20,7 @@ import { DataTable, useDataTable, useDataTableCsvExporter, + useURLCollectionVariables, type DataTableCSVExporterProps, type UseDataTableCsvExporterOptions, } from "@tailor-platform/app-shell"; @@ -54,6 +55,67 @@ There is no duplicate `columns` argument. The component includes its own trigger Query execution and filter/sort normalization remain application-owned. Use the same query and normalized conditions for table loading and CSV fetching; neither this hook nor `DataTable.CSVExporter` automatically discovers or executes the table's query. +## Sharing queries and request criteria + +With a React client such as urql, keep `useQuery` for table loading and execute the same query document imperatively for CSV. Build the normalized filter/sort criteria once per render and reuse the same connection-to-rows adapter for both paths. + +In this application-side example, `ProductsQuery` is the existing table query. It selects the fields used by `columns`, `total`, and the full table `pageInfo` (`hasNextPage`, `hasPreviousPage`, `startCursor`, and `endCursor`). `normalizeCollectionQuery` and `tableMetadata` belong to the application; they are not new CSV exporter APIs. + +```tsx +import { useClient, useQuery } from "urql"; +import type { ResultOf } from "@/graphql"; +import { normalizeCollectionQuery } from "@/lib/normalize-collection-query"; + +const client = useClient(); +const { variables, control } = useURLCollectionVariables({ + tableMetadata: tableMetadata.product, + params: { pageSize: 25 }, +}); +const criteria = { + query: normalizeCollectionQuery(variables.query, tableMetadata.product), + order: variables.order, +}; +const toProductsPage = (connection: NonNullable["products"]>) => ({ + rows: connection.edges.map(({ node }) => node), + pageInfo: connection.pageInfo, + total: connection.total, +}); + +const [{ data, fetching, error }] = useQuery({ + query: ProductsQuery, + variables: { ...variables.pagination, ...criteria }, +}); +const table = useDataTable({ + columns, + control, + data: data?.products ? toProductsPage(data.products) : undefined, + loading: fetching, + error: error ?? null, +}); +const { props } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher: async ({ first, after }) => { + const result = await client + .query(ProductsQuery, { ...criteria, first, after }, { requestPolicy: "network-only" }) + .toPromise(); + if (result.error) throw result.error; + const connection = result.data?.products; + return connection ? toProductsPage(connection) : connection; + }, +}); + + + + +; +``` + +The table and CSV use the same query, normalized criteria, and row projection. Only pagination and request policy differ: the table loads its displayed page, while the exporter starts with `after: null` and follows every page. Do not copy the table's current cursor or backward-pagination arguments into the CSV request. This example focuses on condition sharing; for transport-level cancellation, adapt the fetcher's `signal` to the data client's cancellation mechanism when supported. + +An export keeps using the fetcher from the render that started it. With immutable `criteria` captured by that fetcher, changing filters or sort creates a new fetcher for the next export without changing later pages of the running export. Do not mutate captured query/order objects or read changing criteria from a latest-value ref inside the fetcher: the hook does not deep-clone arbitrary external state. Fixed request criteria also do not imply a server-side snapshot of the records. + +This wiring remains application-owned. The exporter cannot verify that an arbitrary fetcher uses the same query or conditions as the table; sharing them as above prevents accidental duplication rather than adding an automatic consistency guarantee. + ## Column and lifecycle contract - The component derives the schema from the enclosing table's current visible columns and their order at download start, not when the dialog first opens. diff --git a/docs/api/use-data-table-csv-exporter.md b/docs/api/use-data-table-csv-exporter.md index 26783c3ab..0a8ac9444 100644 --- a/docs/api/use-data-table-csv-exporter.md +++ b/docs/api/use-data-table-csv-exporter.md @@ -18,6 +18,7 @@ import { DataTable, useDataTable, useDataTableCsvExporter, + useURLCollectionVariables, type DataTableCSVExporterProps, type UseDataTableCsvExporterOptions, } from "@tailor-platform/app-shell"; @@ -52,6 +53,67 @@ There is no duplicate `columns` argument. The component includes its own trigger Query execution and filter/sort normalization remain application-owned. Use the same query and normalized conditions for table loading and CSV fetching; neither this hook nor `DataTable.CSVExporter` automatically discovers or executes the table's query. +## Sharing queries and request criteria + +With a React client such as urql, keep `useQuery` for table loading and execute the same query document imperatively for CSV. Build the normalized filter/sort criteria once per render and reuse the same connection-to-rows adapter for both paths. + +In this application-side example, `ProductsQuery` is the existing table query. It selects the fields used by `columns`, `total`, and the full table `pageInfo` (`hasNextPage`, `hasPreviousPage`, `startCursor`, and `endCursor`). `normalizeCollectionQuery` and `tableMetadata` belong to the application; they are not new CSV exporter APIs. + +```tsx +import { useClient, useQuery } from "urql"; +import type { ResultOf } from "@/graphql"; +import { normalizeCollectionQuery } from "@/lib/normalize-collection-query"; + +const client = useClient(); +const { variables, control } = useURLCollectionVariables({ + tableMetadata: tableMetadata.product, + params: { pageSize: 25 }, +}); +const criteria = { + query: normalizeCollectionQuery(variables.query, tableMetadata.product), + order: variables.order, +}; +const toProductsPage = (connection: NonNullable["products"]>) => ({ + rows: connection.edges.map(({ node }) => node), + pageInfo: connection.pageInfo, + total: connection.total, +}); + +const [{ data, fetching, error }] = useQuery({ + query: ProductsQuery, + variables: { ...variables.pagination, ...criteria }, +}); +const table = useDataTable({ + columns, + control, + data: data?.products ? toProductsPage(data.products) : undefined, + loading: fetching, + error: error ?? null, +}); +const { props } = useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher: async ({ first, after }) => { + const result = await client + .query(ProductsQuery, { ...criteria, first, after }, { requestPolicy: "network-only" }) + .toPromise(); + if (result.error) throw result.error; + const connection = result.data?.products; + return connection ? toProductsPage(connection) : connection; + }, +}); + + + + +; +``` + +The table and CSV use the same query, normalized criteria, and row projection. Only pagination and request policy differ: the table loads its displayed page, while the exporter starts with `after: null` and follows every page. Do not copy the table's current cursor or backward-pagination arguments into the CSV request. This example focuses on condition sharing; for transport-level cancellation, adapt the fetcher's `signal` to the data client's cancellation mechanism when supported. + +An export keeps using the fetcher from the render that started it. With immutable `criteria` captured by that fetcher, changing filters or sort creates a new fetcher for the next export without changing later pages of the running export. Do not mutate captured query/order objects or read changing criteria from a latest-value ref inside the fetcher: the hook does not deep-clone arbitrary external state. Fixed request criteria also do not imply a server-side snapshot of the records. + +This wiring remains application-owned. The exporter cannot verify that an arbitrary fetcher uses the same query or conditions as the table; sharing them as above prevents accidental duplication rather than adding an automatic consistency guarantee. + ## Column and lifecycle contract - The component derives the schema from the enclosing table's current visible columns and their order at download start, not when the dialog first opens. diff --git a/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx b/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx index a4e879685..1da843917 100644 --- a/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx +++ b/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx @@ -11,7 +11,7 @@ import type { CsvExportPage, UseCsvExporterOptions, } from "@/components/csv-exporter"; -import type { DataTableCSVExporterProps } from "@/index"; +import type { CollectionVariables, DataTableCSVExporterProps } from "@/index"; const wrapper = createAppShellWrapper("en"); type Row = { name: string }; @@ -20,6 +20,7 @@ const fetcher: CsvCursorFetcher = async () => null; afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("useDataTableCsvExporter", () => { @@ -88,6 +89,103 @@ describe("useDataTableCsvExporter", () => { expect(startWithoutColumns).toBeTypeOf("function"); }); + it("keeps the starting filter and sort across pages and uses updated criteria on the next export", async () => { + type Criteria = Pick; + type Request = Parameters>[0] & Criteria; + const initialCriteria: Criteria = { + query: { name: { hasPrefix: "A" } }, + order: [{ field: "name", direction: "Asc" }], + }; + const updatedCriteria: Criteria = { + query: { name: { hasPrefix: "B" } }, + order: [{ field: "name", direction: "Desc" }], + }; + let resolveFirstPage!: (page: CsvExportPage) => void; + const fetchPage = vi + .fn<(request: Request) => Promise>>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstPage = resolve; + }), + ) + .mockResolvedValueOnce({ + rows: [{ name: "Ann" }], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + total: 2, + }) + .mockResolvedValueOnce({ + rows: [{ name: "Bob" }], + pageInfo: { hasNextPage: false, endCursor: "cursor-b" }, + total: 1, + }); + const downloaded: Blob[] = []; + vi.stubGlobal("URL", { + createObjectURL: vi.fn((blob: Blob) => { + downloaded.push(blob); + return "blob:csv"; + }), + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + vi.spyOn(toast, "success").mockReturnValue("toast"); + const tableColumns = [{ id: "name", label: "Name", render: (row: Row) => row.name }]; + const { result, rerender } = renderHook( + ({ criteria }: { criteria: Criteria }) => + useDataTableCsvExporter({ + defaultFilename: "products.csv", + fetcher: (pagination) => fetchPage({ ...pagination, ...criteria }), + }), + { wrapper, initialProps: { criteria: initialCriteria } }, + ); + let exportPromise!: Promise; + act(() => { + exportPromise = result.current.props.exporter.exportCsv("products.csv", tableColumns); + }); + expect(fetchPage).toHaveBeenNthCalledWith(1, { + ...initialCriteria, + first: 1000, + after: null, + signal: expect.any(AbortSignal), + }); + const firstSignal = fetchPage.mock.calls[0]![0].signal; + + rerender({ criteria: updatedCriteria }); + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(firstSignal.aborted).toBe(false); + await act(async () => { + resolveFirstPage({ + rows: [{ name: "Alice" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + total: 2, + }); + await expect(exportPromise).resolves.toBe(true); + }); + + expect(fetchPage).toHaveBeenNthCalledWith(2, { + ...initialCriteria, + first: 1000, + after: "cursor-1", + signal: firstSignal, + }); + expect(await downloaded[0]!.text()).toBe("\uFEFFName\r\nAlice\r\nAnn"); + + await act(async () => { + await expect( + result.current.props.exporter.exportCsv("products.csv", tableColumns), + ).resolves.toBe(true); + }); + expect(fetchPage).toHaveBeenCalledTimes(3); + expect(fetchPage).toHaveBeenNthCalledWith(3, { + ...updatedCriteria, + first: 1000, + after: null, + signal: expect.any(AbortSignal), + }); + expect(fetchPage.mock.calls[2]![0].signal).not.toBe(firstSignal); + expect(await downloaded[1]!.text()).toBe("\uFEFFName\r\nBob"); + }); + it("cancels an in-flight table export", async () => { const pendingFetcher = vi.fn>( ({ signal }) =>