diff --git a/.changeset/bright-csv-exporter.md b/.changeset/bright-csv-exporter.md new file mode 100644 index 000000000..a10bf4606 --- /dev/null +++ b/.changeset/bright-csv-exporter.md @@ -0,0 +1,25 @@ +--- +"@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. 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 }); + + +; +``` + +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 a189dc9c1..5e08e2def 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -332,6 +332,34 @@ "examples": null } }, + "csv-exporter": { + "slug": "csv-exporter", + "kind": "code-backed", + "outline": "docs-src/components/csv-exporter.docs.outline.md", + "output": "docs/components/csv-exporter.md", + "examples": null, + "sources": ["packages/core/src/components/csv-exporter/**"], + "claims": [], + "symbols": [ + "CsvCursorFetcher", + "CsvExportCell", + "CsvExportColumn", + "CsvExportPage", + "CsvExportPhase", + "CsvExporter", + "CsvExporterProps", + "CsvExporterState", + "UseCsvExporterOptions", + "useCsvExporter" + ], + "hashes": { + "typeSurface": "b5d2153372721fdd", + "outline": "1b4bdf78e1a3c576", + "snapshot": null, + "outputMd": "216f1bb6310cc04a", + "examples": null + } + }, "csv-importer": { "slug": "csv-importer", "kind": "code-backed", @@ -404,6 +432,7 @@ "Column", "DataTable", "DataTableAction", + "DataTableCSVExporterProps", "DataTableContextValue", "DataTableData", "DataTableFilterConfig", @@ -433,6 +462,7 @@ "TableOrderableFieldName", "UseCollectionOptions", "UseCollectionReturn", + "UseDataTableCsvExporterOptions", "UseDataTableOptions", "UseDataTableReturn", "createColumnHelper", @@ -442,14 +472,15 @@ "useCollectionVariables", "useDataTable", "useDataTableContext", + "useDataTableCsvExporter", "useURLCollectionVariables", "withURLCollectionState" ], "hashes": { - "typeSurface": "d2910e2d95012c6c", - "outline": "5fc3f188a0f9fb8d", + "typeSurface": "5661e39bd9f8e6fc", + "outline": "ced1f8dd3c0835be", "snapshot": null, - "outputMd": "7a44214b5f1fdc6b", + "outputMd": "e9d635c725336316", "examples": null } }, @@ -1514,6 +1545,57 @@ "examples": null } }, + "use-csv-exporter": { + "slug": "use-csv-exporter", + "kind": "code-backed", + "outline": "docs-src/hooks/use-csv-exporter.docs.outline.md", + "output": "docs/api/use-csv-exporter.md", + "examples": null, + "sources": [ + "packages/core/src/components/csv-exporter/use-csv-exporter.ts", + "packages/core/src/components/csv-exporter/types.ts" + ], + "claims": [], + "symbols": [ + "CsvCursorFetcher", + "CsvExportCell", + "CsvExportColumn", + "CsvExportPage", + "CsvExportPhase", + "CsvExporterProps", + "CsvExporterState", + "UseCsvExporterOptions", + "useCsvExporter" + ], + "hashes": { + "typeSurface": "0aa0163146f1c3d9", + "outline": "e8496bb30fac3fd1", + "snapshot": null, + "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": "c582da24e5f09fad", + "snapshot": null, + "outputMd": "df7e58ddf9cc4cc2", + "examples": null + } + }, "use-override-breadcrumb": { "slug": "use-override-breadcrumb", "kind": "prose", @@ -1692,8 +1774,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": "216f1bb6310cc04a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "7a44214b5f1fdc6b", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "e9d635c725336316", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "bc69f14829004f9d", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1738,6 +1821,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": "b6fc07903fdca468", + "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", @@ -1757,6 +1842,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": "526decbfd2db6555" + "packages/core/skills/app-shell-patterns/SKILL.md": "208232be7e1385b3" } } diff --git a/docs-src/components/csv-exporter.docs.outline.md b/docs-src/components/csv-exporter.docs.outline.md new file mode 100644 index 000000000..0c81263f6 --- /dev/null +++ b/docs-src/components/csv-exporter.docs.outline.md @@ -0,0 +1,63 @@ +--- +kind: code-backed +group: csv-exporter +title: CsvExporter +description: Filename dialog and progress UI for client-side cursor-paginated CSV downloads +sources: + - packages/core/src/components/csv-exporter/** +--- + +# CsvExporter + +`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 + +```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 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 [`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, +}); + + + +; +``` + +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 fdc5a8716..db0727677 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,6 +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` | CSV download button and filename/progress dialog. Pass the dedicated props from [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md). | ### `DataTable.Root` Props @@ -213,6 +215,37 @@ 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` | `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. 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, +}); + + + + + + + + + + + +; +``` + ### `DataTable.Pagination` Props | Prop | Type | Default | Description | diff --git a/docs-src/hooks/use-csv-exporter.docs.outline.md b/docs-src/hooks/use-csv-exporter.docs.outline.md new file mode 100644 index 000000000..c8e33589c --- /dev/null +++ b/docs-src/hooks/use-csv-exporter.docs.outline.md @@ -0,0 +1,111 @@ +--- +kind: code-backed +group: use-csv-exporter +title: useCsvExporter +description: Fetch all cursor-paginated rows and download them as a CSV file with progress and cancellation state +sources: + - packages/core/src/components/csv-exporter/use-csv-exporter.ts + - packages/core/src/components/csv-exporter/types.ts +--- + +# useCsvExporter + +`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 + +```tsx +import { + CsvExporter, + useCsvExporter, + type CsvExportPage, + type CsvExportColumn, +} from "@tailor-platform/app-shell"; +``` + +## Usage + +```tsx +const { open, props } = 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; + 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 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: + +```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[]` | 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`. | + +## 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 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 ``. 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..65e1fe842 --- /dev/null +++ b/docs-src/hooks/use-data-table-csv-exporter.docs.outline.md @@ -0,0 +1,138 @@ +--- +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, + useURLCollectionVariables, + 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. + +## 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. +- 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 new file mode 100644 index 000000000..dbb84c887 --- /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 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 + +```tsx +import { + CsvExporter, + useCsvExporter, + type CsvExportPage, + type CsvExportColumn, +} from "@tailor-platform/app-shell"; +``` + +## Usage + +```tsx +const { open, props } = 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; + 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 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: + +```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[]` | 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`. | + +## 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 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 ``. 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..0a8ac9444 --- /dev/null +++ b/docs/api/use-data-table-csv-exporter.md @@ -0,0 +1,136 @@ +--- +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, + useURLCollectionVariables, + 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. + +## 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. +- 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 new file mode 100644 index 000000000..8c6ae6fe6 --- /dev/null +++ b/docs/components/csv-exporter.md @@ -0,0 +1,61 @@ +--- +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 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 + +```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 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 [`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, +}); + + + +; +``` + +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 7ac281e88..f39397575 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,6 +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` | CSV download button and filename/progress dialog. Pass the dedicated props from [`useDataTableCsvExporter`](../api/use-data-table-csv-exporter.md). | ### `DataTable.Root` Props @@ -207,6 +209,37 @@ 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` | `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. 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, +}); + + + + + + + + + + + +; +``` + ### `DataTable.Pagination` Props | Prop | Type | Default | Description | diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 60ebe0929..98ab97c24 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -69,7 +69,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..5b350e708 --- /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 { + rows, + 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/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx index 254079d41..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,6 +5,7 @@ import { DataTable, Toolbar, useDataTable, + useDataTableCsvExporter, useCollectionVariables, createColumnHelper, type AppShellPageProps, @@ -519,6 +520,15 @@ const DataTableLabPage = () => { rowActions, }); + const { props: exporter } = useDataTableCsvExporter({ + defaultFilename: "invoices.csv", + fetcher: async () => ({ + rows, + 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 @@ -567,6 +577,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 6c50a2a1e..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,6 +6,7 @@ import { Tabs, Toolbar, useDataTable, + useDataTableCsvExporter, useCollectionVariables, createColumnHelper, type CollectionControl, @@ -139,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)); @@ -208,26 +209,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: { @@ -238,6 +243,7 @@ const columns = [ }), // boolean → is / is not, True/False picker. column({ + id: "recurring", label: "Recurring", render: (row) => ( @@ -247,6 +253,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" }, @@ -256,6 +263,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" }, @@ -263,6 +271,7 @@ const columns = [ }), // time → native time input, "HH:mm". column({ + id: "reminderAt", label: "Reminder", render: (row) => row.reminderAt, filter: { field: "reminderAt", type: "time" }, @@ -303,7 +312,11 @@ function StatusTabs({ control }: { control: CollectionControl }) { // Reusable invoice table (own control + data). `toolbar` gets the collection // control so each example can arrange the preset tabs + Add filter differently. -function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => ReactNode }) { +function InvoiceTable({ + toolbar, +}: { + toolbar: (control: CollectionControl, csvExporter: ReactNode) => ReactNode; +}) { const { variables, control } = useCollectionVariables({ params: { pageSize: 10, @@ -329,10 +342,20 @@ function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => Re }, [variables, variablesKey]); const table = useDataTable({ columns, data, loading, control }); + const { props: exporter } = useDataTableCsvExporter({ + defaultFilename: "invoices.csv", + pageSize: 10, + fetcher: ({ first, after }) => + queryInvoices({ + query: variables.query, + order: variables.order, + pagination: { first, after }, + }), + }); return ( - {toolbar(control)} + {toolbar(control, )} @@ -361,7 +384,7 @@ const DataTablePage = () => {

With preset tabs

( + toolbar={(control, csvExporter) => ( @@ -369,6 +392,7 @@ const DataTablePage = () => { + {csvExporter} @@ -386,12 +410,13 @@ const DataTablePage = () => {

Without tabs

( + toolbar={(_control, csvExporter) => ( - + + {csvExporter} diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index e29ab4a89..e8f2fd03c 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -29,7 +29,7 @@ export type GeneratedRouteParams = { "/showcase/alert-tokens": {}; "/showcase/card": {}; "/showcase/colors": {}; - "/showcase/csv-importer": {}; + "/showcase/csv-features": {}; "/showcase/data-table": {}; "/showcase/data-table-lab": {}; "/showcase/data-table-selection": {}; 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..cac94185f --- /dev/null +++ b/packages/core/src/components/csv-exporter/csv-exporter.tsx @@ -0,0 +1,157 @@ +import { Progress } from "@base-ui/react/progress"; +import { useId, useState } from "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 { CsvExporterProps } from "./types"; + +/** @internal Shared filename dialog and progress UI. */ +export function CsvExporterView>({ + open, + onOpenChange, + exporter, +}: CsvExporterProps) { + const t = useCsvExporterT(); + const formId = useId(); + const [filename, setFilename] = useState(exporter.defaultFilename); + const isExporting = + exporter.phase === "fetching" || + exporter.phase === "serializing" || + exporter.phase === "downloading"; + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setFilename(exporter.defaultFilename); + onOpenChange(nextOpen); + }; + + const startExport = async ({ filename: exportFilename }: { filename: string }) => { + if (await exporter.exportCsv(exportFilename)) onOpenChange(false); + }; + + return ( + + + + {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")} + + + )} + +
+
+ ); +} + +/** + * CSV filename dialog and progress UI, opened by a caller-provided trigger. + * + * @example + * ```tsx + * const { open, props } = useCsvExporter({ + * defaultFilename: "products.csv", + * columns, + * fetcher, + * }); + * + * + * ; + * ``` + * + * Use `useDataTableCsvExporter` with `DataTable.CSVExporter` when the export + * should follow the current DataTable column layout. + */ +export function CsvExporter>(props: CsvExporterProps) { + return ; +} + +function CsvExportProgress({ + label, + completed, + total, +}: { + label: string; + completed: number; + total: number | null; +}) { + const isIndeterminate = total == null || total <= 0; + + return ( + + + + + + ); +} 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..1bda19853 --- /dev/null +++ b/packages/core/src/components/csv-exporter/i18n.ts @@ -0,0 +1,47 @@ +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", + dialogTitle: "Download CSV", + filename: "File name", + download: "Download", + csvExporter: "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: "エクスポートするデータがありません", + noColumns: "エクスポートする列を1つ以上選択してください", + exportComplete: (props: { count: number }) => + `${props.count.toLocaleString()} 行をエクスポートしました`, + exportFailed: "CSV のエクスポートに失敗しました", + exportCancelled: "CSV のエクスポートをキャンセルしました", + dialogTitle: "CSV をダウンロード", + filename: "ファイル名", + download: "ダウンロード", + csvExporter: "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..f28aab1fb --- /dev/null +++ b/packages/core/src/components/csv-exporter/index.ts @@ -0,0 +1,12 @@ +export { CsvExporter } from "./csv-exporter"; +export { useCsvExporter } from "./use-csv-exporter"; +export type { + CsvExportPage, + CsvCursorFetcher, + CsvExportCell, + CsvExportColumn, + CsvExportPhase, + 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 new file mode 100644 index 000000000..8a324ec3d --- /dev/null +++ b/packages/core/src/components/csv-exporter/types.ts @@ -0,0 +1,132 @@ +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. + * + * 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; +} + +/** A cursor page of rows returned by a CSV export data source. */ +export interface CsvExportPage { + rows: readonly TRow[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + /** Enables a determinate progress bar when the backend provides a count. */ + 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; + 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 CsvExportableDataTableColumn[]; + +type CsvExportColumnOverride> = + | readonly CsvExportColumn[] + | readonly Column[]; + +/** Current stage of a CSV export. */ +export type CsvExportPhase = + | "idle" + | "fetching" + | "serializing" + | "downloading" + | "success" + | "error" + | "cancelled"; + +/** + * Configuration for `useCsvExporter`. + * + * 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. */ + 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; +} + +/** + * State and actions returned inside `useCsvExporter`'s `props` value. + * + * Standalone `CsvExporter` receives this value through its `exporter` prop. + * Call `exportCsv()` from a custom button to export using the configured schema. + * + * @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; + cancel: () => void; + phase: CsvExportPhase; + progress: { + completed: number; + total: number | null; + }; + error: Error | null; +} + +/** + * Props for `CsvExporter`, managed by `useCsvExporter`. + * + * Spread the hook's `props` return value directly onto the component. + * `DataTable.CSVExporter` requires dedicated props from `useDataTableCsvExporter` + * instead, so its schema always comes from the table. + */ +export interface CsvExporterProps = Record> { + open: boolean; + onOpenChange: (open: boolean) => void; + exporter: CsvExporterState; +} 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.test.tsx b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx new file mode 100644 index 000000000..a2c6940dc --- /dev/null +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.test.tsx @@ -0,0 +1,224 @@ +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 { CsvExportPage, CsvCursorFetcher, UseCsvExporterOptions } from "./types"; + +const wrapper = createAppShellWrapper("en"); + +type ExportTypeTestRow = { name: string }; +const emptyExportTypeTestFetcher: CsvCursorFetcher = async () => null; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +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 = { + 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 { 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>() + .mockResolvedValueOnce({ + rows: [{ id: "1", name: "Alice", formula: "=SUM(A1:A2)" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + total: 2, + }) + .mockResolvedValueOnce({ + rows: [{ 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.props.exporter.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.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', + ); + }); + + 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({ + 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", + ); + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Select at least one column to export"); + expect(download).not.toHaveBeenCalled(); + }); + + 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.props.exporter.exportCsv(); + }); + act(() => result.current.props.exporter.cancel()); + await act(async () => { + await exportPromise; + }); + + await waitFor(() => { + expect(result.current.props.exporter.phase).toBe("cancelled"); + }); + }); + + it.each(["resolves", "rejects"])( + "aborts an in-flight export when unmounted even if the fetch later %s", + async (outcome) => { + 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) => { + 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( + () => + 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({ + rows: [{ 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(); + }, + ); +}); 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..dc450550c --- /dev/null +++ b/packages/core/src/components/csv-exporter/use-csv-exporter.ts @@ -0,0 +1,31 @@ +import { useCsvExportController } from "./use-csv-export-controller"; +import type { UseCsvExporterOptions } from "./types"; + +/** + * 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 { open, props } = useCsvExporter({ + * defaultFilename: "products.csv", + * columns, + * fetcher: ({ first, after, signal }) => client.listProducts({ first, after, signal }), + * }); + * + * + * ; + * ``` + * + * 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>({ + columns, + ...options +}: UseCsvExporterOptions) { + return useCsvExportController(options, columns); +} + +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 new file mode 100644 index 000000000..6ea8fd54e --- /dev/null +++ b/packages/core/src/components/data-table/csv-exporter.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +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 { 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; 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", () => { + 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(); + 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 current visible columns when starting an export", async () => { + const user = userEvent.setup(); + 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); + await user.type(filename, "all-products"); + await user.click(screen.getByRole("button", { name: "Download" })); + + await waitFor(() => { + 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(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 new file mode 100644 index 000000000..f71bbd4ba --- /dev/null +++ b/packages/core/src/components/data-table/csv-exporter.tsx @@ -0,0 +1,49 @@ +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 { DataTableCSVExporterProps } from "./use-data-table-csv-exporter"; +import { useDataTableContext } from "./data-table-context"; + +/** + * CSV exporter integrated with the enclosing DataTable. + * + * 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 } = useDataTableCsvExporter({ defaultFilename: "products.csv", fetcher }); + * + * + * + * ; + * ``` + */ +export function DataTableCSVExporter>( + props: DataTableCSVExporterProps, +) { + const { visibleColumns } = useDataTableContext(); + const t = useCsvExporterT(); + + return ( + <> +
+ +
+ + props.exporter.exportCsv(filename, visibleColumns), + }} + /> + + ); +} + +export type { DataTableCSVExporterProps } from "./use-data-table-csv-exporter"; diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index fc5a0b6d2..1a05eb279 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -46,6 +46,7 @@ import { } from "./toolbar"; import { DataTableColumnSettings } from "./column-settings"; import { DataTablePagination } from "./pagination"; +import { DataTableCSVExporter } from "./csv-exporter"; import { DataTableSelectionAnnouncer, DataTableSelectionBar, @@ -2000,4 +2001,16 @@ export const DataTable = { * `totalPages` is non-null (i.e. the backend returns a `total` count). */ Pagination: DataTablePagination, + /** + * 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/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts index cd7f11cfd..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. @@ -110,7 +123,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 +159,9 @@ export interface ColumnHelper> { * column({ label: "Actions", render: (row) => }) * ``` */ - column: (options: Column) => Column; + column: >( + options: TColumn, + ) => ColumnWithCsvMetadata; /** * Bind table metadata once and return a per-field column factory. * @@ -190,7 +208,8 @@ export interface ColumnHelper> { */ export function createColumnHelper>(): ColumnHelper { return { - column: (options: Column) => column(options), + column: >(options: TColumn) => + ({ ...options }) as ColumnWithCsvMetadata, inferColumns: (tableMetadata: TTable) => inferColumns(tableMetadata), }; diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index bc72bb919..60c0e77a7 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -1,7 +1,12 @@ // DataTable compound component export { DataTable, type DataTablePaginationProps, type DataTableRootProps } from "./data-table"; +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..1da843917 --- /dev/null +++ b/packages/core/src/components/data-table/use-data-table-csv-exporter.test.tsx @@ -0,0 +1,261 @@ +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 { CollectionVariables, DataTableCSVExporterProps } from "@/index"; + +const wrapper = createAppShellWrapper("en"); +type Row = { name: string }; +const fetcher: CsvCursorFetcher = async () => null; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +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("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 }) => + 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 9a4cde02d..462d41d20 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -258,6 +258,18 @@ export { type ParsedRow, type InferCsvRow, } from "./components/csv-importer"; +export { + CsvExporter, + useCsvExporter, + type CsvExportPage, + type CsvCursorFetcher, + type CsvExportCell, + type CsvExportColumn, + type CsvExportPhase, + type CsvExporterState, + type CsvExporterProps, + type UseCsvExporterOptions, +} from "./components/csv-exporter"; // Collection (shared across DataTable, Kanban, Gantt, etc.) export { @@ -295,6 +307,7 @@ export { export { DataTable, useDataTable, + useDataTableCsvExporter, useDataTableContext, createColumnHelper, type DataTablePaginationProps, @@ -311,6 +324,8 @@ export { type UseDataTableReturn, 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";