diff --git a/src/components/Table/GridTable.stories.tsx b/src/components/Table/GridTable.stories.tsx index 491c4fa67..ac374cbe0 100644 --- a/src/components/Table/GridTable.stories.tsx +++ b/src/components/Table/GridTable.stories.tsx @@ -68,6 +68,35 @@ export function ClientSideSorting() { ); } +export function ClientSideCsv() { + const api = useGridTableApi(); + const columns: GridColumn[] = [ + // Given column one returns JSX, but defines a `sortValue` + { header: "Name", data: ({ name }) => ({ content:
{name}
, sortValue: name }) }, + // And column two returns a number + { header: "Value", data: ({ value }) => value }, + // And column three returns a string + { header: "Value", data: ({ value }) => String(value) }, + // And column four returns JSX with nothing else + { header: "Action", data: () =>
Actions
}, + ]; + return ( +
+ +
+ ); +} + export const Hovering = newStory( () => { const nameColumn: GridColumn = { header: "Name", data: ({ name }) => name }; diff --git a/src/components/Table/GridTable.test.tsx b/src/components/Table/GridTable.test.tsx index bbbe9e9c5..b29b10517 100644 --- a/src/components/Table/GridTable.test.tsx +++ b/src/components/Table/GridTable.test.tsx @@ -2,7 +2,7 @@ import { act } from "@testing-library/react"; import { MutableRefObject, useContext, useMemo, useState } from "react"; import { GridDataRow } from "src/components/Table/components/Row"; import { GridTable, OnRowSelect, setRunningInJest } from "src/components/Table/GridTable"; -import { GridTableApi, useGridTableApi } from "src/components/Table/GridTableApi"; +import { GridTableApi, GridTableApiImpl, useGridTableApi } from "src/components/Table/GridTableApi"; import { RowStyles } from "src/components/Table/TableStyles"; import { GridColumn } from "src/components/Table/types"; import { calcColumnSizes, column, generateColumnId, selectColumn } from "src/components/Table/utils/columns"; @@ -12,7 +12,7 @@ import { TableStateContext } from "src/components/Table/utils/TableState"; import { emptyCell, matchesFilter } from "src/components/Table/utils/utils"; import { Css, Palette } from "src/Css"; import { useComputed } from "src/hooks"; -import { Checkbox, SelectField, TextField } from "src/inputs"; +import { SelectField, TextField } from "src/inputs"; import { noop } from "src/utils"; import { cell, @@ -3787,6 +3787,40 @@ describe("GridTable", () => { const p = render( columns={nestedColumns} rows={rows} />); await expect(p).rejects.toThrow("Duplicate row id 1"); }); + + it("can download csvs", async () => { + let api: GridTableApi | undefined; + + const columns: GridColumn[] = [ + // Given a column returns JSX, but defines a `sortValue` + { header: "Name", data: ({ name }) => ({ content:
{name}
, sortValue: name }) }, + // And a column returns a number + { header: "Value", data: ({ value }) => value }, + // And a column returns a string + { header: "Value", data: ({ value }) => String(value) }, + // And a column returns a string with a comma in it + { header: "Value", data: ({ value }) => `${value},${value}` }, + // And a column returns a string with a quote in it + { header: "Value", data: ({ value }) => `a quoted "${value}" value` }, + // And a column returns undefined + { header: "Value", data: () => undefined }, + // And a column returns JSX with nothing else + { header: "Action", data: () =>
Actions
, isAction: true }, + ]; + + function Test() { + api = useGridTableApi(); + return api={api} columns={columns} rows={rows} />; + } + + await render(); + + expect((api as GridTableApiImpl).generateCsvContent()).toEqual([ + "Name,Value,Value,Value,Value,Value", + `foo,1,1,"1,1","a quoted ""1"" value",`, + `bar,2,2,"2,2","a quoted ""2"" value",`, + ]); + }); }); function Collapse({ id }: { id: string }) { @@ -3799,22 +3833,6 @@ function Collapse({ id }: { id: string }) { ); } -function Select({ id }: { id: string }) { - const { tableState } = useContext(TableStateContext); - const state = useComputed(() => tableState.getSelected(id), [tableState]); - const selected = state === "checked" ? true : state === "unchecked" ? false : "indeterminate"; - return ( - { - tableState.selectRow(id, selected); - }} - /> - ); -} - function expectRenderedRows(...rowIds: string[]): void { expect(renderedNameColumn).toEqual(rowIds); // Reset as a side effect so the test's next call to `expectRenderedRows` will diff --git a/src/components/Table/GridTableApi.ts b/src/components/Table/GridTableApi.ts index ce8bc916f..3510a9ed0 100644 --- a/src/components/Table/GridTableApi.ts +++ b/src/components/Table/GridTableApi.ts @@ -2,10 +2,20 @@ import { comparer } from "mobx"; import { computedFn } from "mobx-utils"; import { MutableRefObject, useMemo } from "react"; import { VirtuosoHandle } from "react-virtuoso"; -import { createRowLookup, GridRowLookup } from "src/components/index"; +import { + applyRowFn, + createRowLookup, + GridRowLookup, + isGridCellContent, + isJSX, + maybeApplyFunction, + MaybeFn, +} from "src/components/index"; import { GridDataRow } from "src/components/Table/components/Row"; import { DiscriminateUnion, Kinded } from "src/components/Table/types"; import { TableState } from "src/components/Table/utils/TableState"; +import { maybeCall } from "src/utils"; +import { Properties } from "src/Css"; /** * Creates an `api` handle to drive a `GridTable`. @@ -63,6 +73,20 @@ export type GridTableApi = { getVisibleColumnIds(): string[]; setVisibleColumns(ids: string[]): void; + + /** + * Triggers the table's current content to be downloaded as a CSV file. + * + * This currently assumes client-side pagination/sorting, i.e. we have the full dataset in memory. + */ + downloadToCsv(fileName: string): void; + + /** + * Copies the table's current content to the clipboard. + * + * This currently assumes client-side pagination/sorting, i.e. we have the full dataset in memory. + */ + copyToClipboard(): Promise; }; /** Adds per-row methods to the `api`, i.e. for getting currently-visible children. */ @@ -175,6 +199,75 @@ export class GridTableApiImpl implements GridTableApi { public deleteRows(ids: string[]) { this.tableState.deleteRows(ids); } + + public downloadToCsv(fileName: string): void { + // Create a link element, set the download attribute with the provided filename + const link = document.createElement("a"); + if (link.download === undefined) throw new Error("This browser does not support the download attribute."); + // Create a Blob from the CSV content + const url = URL.createObjectURL( + new Blob([this.generateCsvContent().join("\n")], { type: "text/csv;charset=utf-8;" }), + ); + link.setAttribute("href", url); + link.setAttribute("download", fileName); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + public copyToClipboard(): Promise { + // Copy the CSV content to the clipboard + const content = this.generateCsvContent().join("\n"); + return navigator.clipboard.writeText(content).catch((err) => { + // Let the user know the copy failed... + window.alert("Failed to copy to clipboard, probably due to browser restrictions."); + throw err; + }); + } + + // visibleForTesting, not part of the GridTableApi + // ...although maybe it could be public someday, to allow getting the raw the CSV content + // and then sending it somewhere else, like directly to a gsheet. + public generateCsvContent(): string[] { + // Convert the array of rows into CSV format + return this.tableState.visibleRows.map((rs) => { + const values = this.tableState.visibleColumns + .filter((c) => !c.isAction) + .map((c) => { + // Just guessing for level=1 + const maybeContent = applyRowFn(c, rs.row, this as any as GridRowApi, 1, true, undefined); + if (isGridCellContent(maybeContent)) { + const cell = maybeContent; + const content = maybeApply(cell.content); + // Anything not isJSX (like a string) we can put into the CSV directly + if (!isJSX(content)) return content; + // Otherwise use the value/sortValue values + return cell.value ? maybeApply(cell.value) : cell.sortValue ? maybeApply(cell.sortValue) : "-"; + } else { + // ReactNode + return isJSX(maybeContent) ? "-" : maybeContent; + } + }); + return values.map(toCsvString).map(escapeCsvValue).join(","); + }); + } +} + +function toCsvString(value: any): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number") return value.toString(); + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value); +} + +function escapeCsvValue(value: string): string { + // Wrap values with special chars in quotes, and double quotes themselves + if (value.includes('"') || value.includes(",") || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; } function bindMethods(instance: any): void { @@ -182,3 +275,7 @@ function bindMethods(instance: any): void { if (instance[key] instanceof Function && key !== "constructor") instance[key] = instance[key].bind(instance); }); } + +export function maybeApply(maybeFn: MaybeFn): T { + return typeof maybeFn === "function" ? (maybeFn as any)() : maybeFn; +} diff --git a/src/components/Table/components/Row.tsx b/src/components/Table/components/Row.tsx index e782fff90..c014883cb 100644 --- a/src/components/Table/components/Row.tsx +++ b/src/components/Table/components/Row.tsx @@ -15,6 +15,7 @@ import { ensureClientSideSortValueIsSortable } from "src/components/Table/utils/ import { TableStateContext } from "src/components/Table/utils/TableState"; import { applyRowFn, + DragData, EXPANDABLE_HEADER, getAlignment, getFirstOrLastCellCss, @@ -198,14 +199,18 @@ function RowImpl(props: RowProps): ReactElement { currentColspan -= 1; return null; } - const maybeContent = applyRowFn(column as GridColumnWithId, row, rowApi, level, isExpanded, { + + // Combine all our drag stuff into a mini-context/parameter object... + const dragData: DragData = { rowRenderRef: ref, onDragStart, onDragEnd, onDrop, onDragEnter, onDragOver: onDragOverDebounced, - }); + }; + + const maybeContent = applyRowFn(column as GridColumnWithId, row, rowApi, level, isExpanded, dragData); // Only use the `numExpandedColumns` as the `colspan` when rendering the "Expandable Header" currentColspan = diff --git a/src/components/Table/components/cell.tsx b/src/components/Table/components/cell.tsx index 2ad16b8f7..4cc0f29f2 100644 --- a/src/components/Table/components/cell.tsx +++ b/src/components/Table/components/cell.tsx @@ -9,10 +9,14 @@ import { Css, Properties, Typography } from "src/Css"; /** * Allows a cell to be more than just a RectNode, i.e. declare its alignment or * primitive value for filtering and sorting. + * + * For a given column, the `GridColumn` can either return a static `GridCellContent`, or + * more likely use a function that returns a per-column/per-row `GridCellContent` that defines + * the value (and it's misc alignment/css/etc) for this specific cell. */ export type GridCellContent = { /** The JSX content of the cell. Virtual tables that client-side sort should use a function to avoid perf overhead. */ - content: ReactNode | (() => ReactNode); + content: MaybeFn; alignment?: GridCellAlignment; /** Allow value to be a function in case it's a dynamic value i.e. reading from an inline-edited proxy. */ value?: MaybeFn; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 1de23fc96..4c727b8e4 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -33,8 +33,9 @@ export type DiscriminateUnion = T extends export type GridColumn = { /** Require a render function for each row `kind`. */ [K in R["kind"]]: - | string - | GridCellContent + | string // static data, i.e. `firstNameColumn = { header: "First Name" }` + | GridCellContent // static-ish cell, i.e. `firstNameColumn = { header: { content: ... } }` + // Functions for dynamic data, i.e. `firstNameColumn = { header: (data) => data.firstName }` | (DiscriminateUnion extends { data: infer D } ? ( data: D, diff --git a/src/components/Table/utils/utils.tsx b/src/components/Table/utils/utils.tsx index 930638652..afd30de1c 100644 --- a/src/components/Table/utils/utils.tsx +++ b/src/components/Table/utils/utils.tsx @@ -174,7 +174,7 @@ export function getFirstOrLastCellCss( } /** A heuristic to detect the result of `React.createElement` / i.e. JSX. */ -function isJSX(content: any): boolean { +export function isJSX(content: any): boolean { return typeof content === "object" && content && "type" in content && "props" in content; }