From de5542907b114b350a4d771fe51322149d0de9d6 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Sat, 31 Aug 2024 16:40:09 -0500 Subject: [PATCH 1/7] feat: Add GridTable csv export. --- src/components/Table/GridTable.stories.tsx | 29 ++++++++++ src/components/Table/GridTable.test.tsx | 44 +++++++++------ src/components/Table/GridTableApi.ts | 64 +++++++++++++++++++++- src/components/Table/components/Row.tsx | 9 ++- src/components/Table/components/cell.tsx | 6 +- src/components/Table/types.ts | 5 +- src/components/Table/utils/utils.tsx | 2 +- 7 files changed, 135 insertions(+), 24 deletions(-) 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..cc8261c1a 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"; @@ -27,6 +27,7 @@ import { wait, withRouter, } from "src/utils/rtl"; +import { Button } from "src"; // Most of our tests use this simple Row and 2 columns type Data = { name: string; value: number | undefined | null }; @@ -3787,6 +3788,31 @@ 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 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
, isAction: true }, + ]; + + function Test() { + const _api = useGridTableApi(); + api = _api; + return api={_api} columns={columns} rows={rows} />; + } + + await render(); + + expect((api as GridTableApiImpl).generateCsvContent()).toEqual(["Name,Value,Value", "foo,1,1", "bar,2,2"]); + }); }); function Collapse({ id }: { id: string }) { @@ -3799,22 +3825,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..fcfe2d8a3 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,13 @@ 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; }; /** Adds per-row methods to the `api`, i.e. for getting currently-visible children. */ @@ -175,6 +192,47 @@ 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); + } + + // visibleForTesting, not part of the GridTableApi + 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.join(","); + }); + } } function bindMethods(instance: any): void { @@ -182,3 +240,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; } From e6bad4eb686c34dbefef0f7ea688d864f3757451 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Sat, 31 Aug 2024 21:21:39 -0500 Subject: [PATCH 2/7] Better string handling. --- src/components/Table/GridTable.test.tsx | 20 +++++++++++++++----- src/components/Table/GridTableApi.ts | 20 +++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/components/Table/GridTable.test.tsx b/src/components/Table/GridTable.test.tsx index cc8261c1a..d0ed610a1 100644 --- a/src/components/Table/GridTable.test.tsx +++ b/src/components/Table/GridTable.test.tsx @@ -3793,13 +3793,19 @@ describe("GridTable", () => { let api: GridTableApi | undefined; const columns: GridColumn[] = [ - // Given column one returns JSX, but defines a `sortValue` + // Given a column returns JSX, but defines a `sortValue` { header: "Name", data: ({ name }) => ({ content:
{name}
, sortValue: name }) }, - // And column two returns a number + // And a column returns a number { header: "Value", data: ({ value }) => value }, - // And column three returns a string + // And a column returns a string { header: "Value", data: ({ value }) => String(value) }, - // And column four returns JSX with nothing else + // 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 }, ]; @@ -3811,7 +3817,11 @@ describe("GridTable", () => { await render(); - expect((api as GridTableApiImpl).generateCsvContent()).toEqual(["Name,Value,Value", "foo,1,1", "bar,2,2"]); + 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",`, + ]); }); }); diff --git a/src/components/Table/GridTableApi.ts b/src/components/Table/GridTableApi.ts index fcfe2d8a3..03cd0fd56 100644 --- a/src/components/Table/GridTableApi.ts +++ b/src/components/Table/GridTableApi.ts @@ -230,11 +230,29 @@ export class GridTableApiImpl implements GridTableApi { return isJSX(maybeContent) ? "-" : maybeContent; } }); - return values.join(","); + return values.map(toCsvString).map(escapeCSVField).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 escapeCSVField(field: string): string { + if (field.includes('"') || field.includes(",") || field.includes("\n")) { + // Escape quotes by doubling them + field = field.replace(/"/g, '""'); + // Wrap the field in double quotes + return `"${field}"`; + } + return field; +} + function bindMethods(instance: any): void { Object.getOwnPropertyNames(Object.getPrototypeOf(instance)).forEach((key) => { if (instance[key] instanceof Function && key !== "constructor") instance[key] = instance[key].bind(instance); From 9d0460fdd803a67b197e0849066cef421a47049c Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Sun, 1 Sep 2024 08:20:08 -0500 Subject: [PATCH 3/7] Renames. --- src/components/Table/GridTableApi.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Table/GridTableApi.ts b/src/components/Table/GridTableApi.ts index 03cd0fd56..9e130f9c8 100644 --- a/src/components/Table/GridTableApi.ts +++ b/src/components/Table/GridTableApi.ts @@ -210,6 +210,8 @@ export class GridTableApiImpl implements GridTableApi { } // 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) => { @@ -230,7 +232,7 @@ export class GridTableApiImpl implements GridTableApi { return isJSX(maybeContent) ? "-" : maybeContent; } }); - return values.map(toCsvString).map(escapeCSVField).join(","); + return values.map(toCsvString).map(escapeCsvValue).join(","); }); } } @@ -243,14 +245,12 @@ function toCsvString(value: any): string { return String(value); } -function escapeCSVField(field: string): string { - if (field.includes('"') || field.includes(",") || field.includes("\n")) { - // Escape quotes by doubling them - field = field.replace(/"/g, '""'); - // Wrap the field in double quotes - return `"${field}"`; +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 field; + return value; } function bindMethods(instance: any): void { From 075c25dfacdd4d3b08ded0c52dab855273c26fe6 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Sun, 1 Sep 2024 08:23:39 -0500 Subject: [PATCH 4/7] Remove imports. --- src/components/Table/GridTable.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Table/GridTable.test.tsx b/src/components/Table/GridTable.test.tsx index d0ed610a1..e3257264e 100644 --- a/src/components/Table/GridTable.test.tsx +++ b/src/components/Table/GridTable.test.tsx @@ -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, @@ -27,7 +27,6 @@ import { wait, withRouter, } from "src/utils/rtl"; -import { Button } from "src"; // Most of our tests use this simple Row and 2 columns type Data = { name: string; value: number | undefined | null }; From f33d2825c17c52700ffe5a2d809893298ec0194f Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Sun, 1 Sep 2024 08:25:52 -0500 Subject: [PATCH 5/7] Remove the _api variable. --- src/components/Table/GridTable.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Table/GridTable.test.tsx b/src/components/Table/GridTable.test.tsx index e3257264e..b29b10517 100644 --- a/src/components/Table/GridTable.test.tsx +++ b/src/components/Table/GridTable.test.tsx @@ -3809,9 +3809,8 @@ describe("GridTable", () => { ]; function Test() { - const _api = useGridTableApi(); - api = _api; - return api={_api} columns={columns} rows={rows} />; + api = useGridTableApi(); + return api={api} columns={columns} rows={rows} />; } await render(); From d691cf319da4bcf2c8a5e30ebcdafe481a6bf165 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Sun, 1 Sep 2024 11:36:22 -0500 Subject: [PATCH 6/7] Add copyToClipboard. --- src/components/Table/GridTableApi.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/Table/GridTableApi.ts b/src/components/Table/GridTableApi.ts index 9e130f9c8..3929636ca 100644 --- a/src/components/Table/GridTableApi.ts +++ b/src/components/Table/GridTableApi.ts @@ -80,6 +80,13 @@ export type GridTableApi = { * 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. */ @@ -209,6 +216,11 @@ export class GridTableApiImpl implements GridTableApi { document.body.removeChild(link); } + public copyToClipboard(): Promise { + // Copy the CSV content to the clipboard + return navigator.clipboard.writeText(this.generateCsvContent().join("\n")); + } + // 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. From bfcac5ed3920d87499aa7b84735e867655fe326f Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Tue, 3 Sep 2024 09:04:59 -0500 Subject: [PATCH 7/7] Add a window.alert. --- src/components/Table/GridTableApi.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Table/GridTableApi.ts b/src/components/Table/GridTableApi.ts index 3929636ca..3510a9ed0 100644 --- a/src/components/Table/GridTableApi.ts +++ b/src/components/Table/GridTableApi.ts @@ -218,7 +218,12 @@ export class GridTableApiImpl implements GridTableApi { public copyToClipboard(): Promise { // Copy the CSV content to the clipboard - return navigator.clipboard.writeText(this.generateCsvContent().join("\n")); + 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