-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Add GridTable csv export. #1063
Changes from 6 commits
de55429
e6bad4e
9d0460f
075c25d
f33d282
d691cf3
bfcac5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<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. */ | ||
|
@@ -175,10 +199,78 @@ 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 | ||
return navigator.clipboard.writeText(this.generateCsvContent().join("\n")); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: This can throw an error if copying to the clipboard is not allowed. I'm not sure how this would impact the rest of the application if the error is thrown (hopefully not take it down completely), but makes me wonder if we should wrap in a try/catch and report to DataDog and maybe a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, afaiu it would return a rejected Promise, instead of immediately throwing, so I was thinking the caller could just deal with it -- but I suppose centralized error handling would be nice, so I added a q&d window.alert for now (but still returning a rejected promise, with the idea that the caller UI could show like a red "x" instead of a green "check"). In terms of logging, I think dd should be logging all of our rejected promises, which is what this would be atm... 🤔 |
||
} | ||
|
||
// 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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<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> = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: Thanks for cleaning up |
||
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 = | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was no longer used