Skip to content

Commit

Permalink
feat: Add GridTable csv export. (#1063)
Browse files Browse the repository at this point in the history
As easy as:

```
const api = useGridTableApi();

<Button onClick={() => api.downloadToCsv("report.csv")} label="CSV" />
```

Currently assumes all of the data is already available client-side --
not really sure how this would work with server-side driven pagination.

Also assumes we want all of the columns/rows included, and that they're
directly mapped from the `content` / `value` keys. We could also add a
`csvValue` to let columns provide CSV-specific values.


![image](https://github.com/user-attachments/assets/46b2a3da-f16d-4cdf-ae7b-a85f7b6db32b)
  • Loading branch information
stephenh authored Sep 3, 2024
1 parent ee1b3e2 commit 3d61e5d
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 25 deletions.
29 changes: 29 additions & 0 deletions src/components/Table/GridTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ export function ClientSideSorting() {
);
}

export function ClientSideCsv() {
const api = useGridTableApi<Row>();
const columns: GridColumn<Row>[] = [
// Given column one returns JSX, but defines a `sortValue`
{ header: "Name", data: ({ name }) => ({ content: <div>{name}</div>, 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: () => <div>Actions</div> },
];
return (
<div>
<GridTable
api={api}
columns={columns}
rows={[
simpleHeader,
{ kind: "data", id: "1", data: { name: "c", value: 1 } },
{ kind: "data", id: "2", data: { name: "B", value: 2 } },
{ kind: "data", id: "3", data: { name: "a", value: 3 } },
]}
/>
<Button label="Download" onClick={() => api.downloadToCsv("test.csv")} />
</div>
);
}

export const Hovering = newStory(
() => {
const nameColumn: GridColumn<Row> = { header: "Name", data: ({ name }) => name };
Expand Down
54 changes: 36 additions & 18 deletions src/components/Table/GridTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -3787,6 +3787,40 @@ describe("GridTable", () => {
const p = render(<GridTable<NestedRow> columns={nestedColumns} rows={rows} />);
await expect(p).rejects.toThrow("Duplicate row id 1");
});

it("can download csvs", async () => {
let api: GridTableApi<Row> | undefined;

const columns: GridColumn<Row>[] = [
// Given a column returns JSX, but defines a `sortValue`
{ header: "Name", data: ({ name }) => ({ content: <div>{name}</div>, 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: () => <div>Actions</div>, isAction: true },
];

function Test() {
api = useGridTableApi<Row>();
return <GridTable<Row> api={api} columns={columns} rows={rows} />;
}

await render(<Test />);

expect((api as GridTableApiImpl<Row>).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 }) {
Expand All @@ -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 (
<Checkbox
label="Select"
checkboxOnly={true}
selected={selected}
onChange={(selected) => {
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
Expand Down
99 changes: 98 additions & 1 deletion src/components/Table/GridTableApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -63,6 +73,20 @@ export type GridTableApi<R extends Kinded> = {

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<void>;
};

/** Adds per-row methods to the `api`, i.e. for getting currently-visible children. */
Expand Down Expand Up @@ -175,10 +199,83 @@ export class GridTableApiImpl<R extends Kinded> implements GridTableApi<R> {
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<void> {
// 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<R>, 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 {
Object.getOwnPropertyNames(Object.getPrototypeOf(instance)).forEach((key) => {
if (instance[key] instanceof Function && key !== "constructor") instance[key] = instance[key].bind(instance);
});
}

export function maybeApply<T>(maybeFn: MaybeFn<T>): T {
return typeof maybeFn === "function" ? (maybeFn as any)() : maybeFn;
}
9 changes: 7 additions & 2 deletions src/components/Table/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -198,14 +199,18 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
currentColspan -= 1;
return null;
}
const maybeContent = applyRowFn(column as GridColumnWithId<R>, row, rowApi, level, isExpanded, {

// Combine all our drag stuff into a mini-context/parameter object...
const dragData: DragData<R> = {
rowRenderRef: ref,
onDragStart,
onDragEnd,
onDrop,
onDragEnter,
onDragOver: onDragOverDebounced,
});
};

const maybeContent = applyRowFn(column as GridColumnWithId<R>, row, rowApi, level, isExpanded, dragData);

// Only use the `numExpandedColumns` as the `colspan` when rendering the "Expandable Header"
currentColspan =
Expand Down
6 changes: 5 additions & 1 deletion src/components/Table/components/cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactNode>;
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<number | string | Date | boolean | null | undefined>;
Expand Down
5 changes: 3 additions & 2 deletions src/components/Table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export type DiscriminateUnion<T, K extends keyof T, V extends T[K]> = T extends
export type GridColumn<R extends Kinded> = {
/** 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<R, "kind", K> extends { data: infer D }
? (
data: D,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Table/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export function getFirstOrLastCellCss<R extends Kinded>(
}

/** 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;
}

Expand Down

0 comments on commit 3d61e5d

Please sign in to comment.