-
Notifications
You must be signed in to change notification settings - Fork 4
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
Export records from tables #1646
base: john/20202-page-and-record-counts
Are you sure you want to change the base?
Changes from all commits
dcd9cbb
74c328c
7e970c5
f2527f8
3bb09a5
7be5b16
057bd97
ec4b1b8
353aa3e
53292a2
a194d16
24e3439
7fbb22b
2974dca
6ba0315
c41e404
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 |
---|---|---|
@@ -0,0 +1,151 @@ | ||
import { useState, useEffect } from "react"; | ||
import Alert from "react-bootstrap/Alert"; | ||
import Button from "react-bootstrap/Button"; | ||
import Modal from "react-bootstrap/Modal"; | ||
import Spinner from "react-bootstrap/Spinner"; | ||
import { useQuery } from "@/utils/graphql"; | ||
import { unparse } from "papaparse"; | ||
import AlignedLabel from "./AlignedLabel"; | ||
import { FaCircleInfo, FaDownload } from "react-icons/fa6"; | ||
import { formatDate } from "@/utils/formatters"; | ||
|
||
/** | ||
* Generate the CSV export filename | ||
*/ | ||
const formatFileName = (exportFilename?: string) => | ||
`${exportFilename || "export"}-${formatDate(new Date().toISOString())}.csv`; | ||
|
||
interface TableExportModalProps { | ||
/** | ||
* The name that will be given to the exported file, excluding | ||
* the file extension | ||
*/ | ||
exportFilename?: string; | ||
/** | ||
* A callback fired when either the modal backdrop is clicked, or the | ||
* escape key is pressed | ||
*/ | ||
onClose: () => void; | ||
/** | ||
* The graphql query to use | ||
*/ | ||
query: string; | ||
/** | ||
* If the modal should be visible or hidden | ||
*/ | ||
show: boolean; | ||
/** | ||
* The number of records to be exported | ||
*/ | ||
totalRecordCount: number; | ||
/** | ||
* The typename of the query root that will be used to access the rows returned by the query | ||
*/ | ||
typename: string; | ||
} | ||
|
||
/** | ||
* UI component which provides a CSV download of the provided query | ||
*/ | ||
export default function TableExportModal<T extends Record<string, unknown>>({ | ||
exportFilename, | ||
onClose, | ||
query, | ||
totalRecordCount, | ||
show, | ||
typename, | ||
}: TableExportModalProps) { | ||
/** | ||
* TODO: exclude aggregations from export | ||
* https://github.com/cityofaustin/atd-data-tech/issues/20481 | ||
*/ | ||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null); | ||
const { data, isLoading, error } = useQuery<T>({ | ||
// don't fetch until this modal is visible | ||
query: show ? query : null, | ||
typename, | ||
hasAggregates: false, | ||
}); | ||
|
||
/** | ||
* Hook which updates creates the CSV download blob when data becomes available | ||
*/ | ||
useEffect(() => { | ||
if (!isLoading && data) { | ||
const csvContent = unparse(data, { | ||
quotes: true, | ||
header: true, | ||
}); | ||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); | ||
const url = URL.createObjectURL(blob); | ||
setDownloadUrl(url); | ||
return () => { | ||
// cleanup on unmount | ||
URL.revokeObjectURL(url); | ||
setDownloadUrl(null); | ||
}; | ||
} | ||
}, [isLoading, data]); | ||
|
||
if (error) { | ||
console.error(error); | ||
} | ||
|
||
return ( | ||
<Modal show={show} onHide={onClose} animation={false} backdrop="static"> | ||
<Modal.Header closeButton> | ||
<Modal.Title>Download data</Modal.Title> | ||
</Modal.Header> | ||
<Modal.Body> | ||
<Alert | ||
variant="info" | ||
className="d-flex justify-content-between align-items-center" | ||
> | ||
<FaCircleInfo className="me-3 fs-4" /> | ||
<div> | ||
You are about to download{" "} | ||
<span className="fw-bold">{`${totalRecordCount.toLocaleString()} `}</span> | ||
<span>{`record${totalRecordCount === 1 ? "" : "s"}`}</span> — this | ||
may take a few minutes for larger downloads | ||
</div> | ||
</Alert> | ||
</Modal.Body> | ||
<Modal.Footer> | ||
<Button variant="secondary" onClick={onClose}> | ||
Cancel | ||
</Button> | ||
{downloadUrl && ( | ||
<Button | ||
href={downloadUrl || "#"} | ||
download={formatFileName(exportFilename)} | ||
as="a" | ||
> | ||
Comment on lines
+118
to
+122
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. Since the data is pulled together as shown by the spinner on the download button before it can be clicked, can we close this modal automatically once or very shortly after the download button is clicked by the user? I can't see any situation where a user needs to download the same data twice in rapid succession. In fact, I think we want to avoid making that easy to do. The model closing will give the user some visual feedback that the operation has occurred, and as it is, I think this relies too much on the browser's gloss outside of the viewport to telegraph that activity to the user, if it does telegraph it at all. If we can't close the modal because maybe we need to keep it mounted to preserve the URL held in the |
||
<AlignedLabel> | ||
<FaDownload className="me-2" /> | ||
Download | ||
</AlignedLabel> | ||
</Button> | ||
)} | ||
{error && ( | ||
<Alert variant="danger"> | ||
<p>Something went wrong</p> | ||
<p> | ||
<details> | ||
<summary>Error</summary> | ||
{String(error)} | ||
</details> | ||
</p> | ||
</Alert> | ||
)} | ||
{isLoading && ( | ||
<Button disabled variant="outline-primary"> | ||
<AlignedLabel> | ||
<Spinner size="sm" className="me-2" /> | ||
<span>Loading...</span> | ||
</AlignedLabel> | ||
</Button> | ||
)} | ||
</Modal.Footer> | ||
</Modal> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,40 +6,58 @@ import ButtonToolbar from "react-bootstrap/ButtonToolbar"; | |
import AlignedLabel from "./AlignedLabel"; | ||
import { QueryConfig } from "@/utils/queryBuilder"; | ||
import "react-datepicker/dist/react-datepicker.css"; | ||
import { FaAngleLeft, FaAngleRight } from "react-icons/fa6"; | ||
import { HasuraAggregateData } from "@/types/graphql"; | ||
import { FaAngleLeft, FaAngleRight, FaDownload } from "react-icons/fa6"; | ||
|
||
interface PaginationControlProps { | ||
queryConfig: QueryConfig; | ||
setQueryConfig: Dispatch<SetStateAction<QueryConfig>>; | ||
recordCount: number; | ||
totalRecordCount: number; | ||
isLoading: boolean; | ||
aggregateData?: HasuraAggregateData; | ||
onClickDownload: () => void; | ||
exportable: boolean; | ||
} | ||
|
||
/** | ||
* UI component that controls pagination by setting the | ||
* QueryConfig offset | ||
*/ | ||
export default function TablePaginationControls({ | ||
aggregateData, | ||
queryConfig, | ||
setQueryConfig, | ||
recordCount, | ||
totalRecordCount, | ||
isLoading, | ||
onClickDownload, | ||
exportable, | ||
}: PaginationControlProps) { | ||
const currentPageNum = queryConfig.offset / queryConfig.limit + 1; | ||
const totalRecords = aggregateData?.aggregate?.count || 0; | ||
|
||
return ( | ||
<ButtonToolbar> | ||
<div className="text-nowrap text-secondary d-flex align-items-center me-2"> | ||
{totalRecords > 0 && ( | ||
<span>{`${totalRecords.toLocaleString()} records`}</span> | ||
{totalRecordCount > 0 && ( | ||
<> | ||
<span className="me-2">{`${totalRecordCount.toLocaleString()} record${ | ||
totalRecordCount === 1 ? "" : "s" | ||
}`}</span> | ||
{exportable && ( | ||
<Button | ||
variant="outline-primary" | ||
style={{ border: "none" }} | ||
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. This feels weird to me. To use the Aside from that, why use |
||
onClick={onClickDownload} | ||
> | ||
<AlignedLabel> | ||
<FaDownload className="me-2" /> | ||
<span>Download</span> | ||
</AlignedLabel> | ||
</Button> | ||
)} | ||
</> | ||
)} | ||
{totalRecords <= 0 && <span>No results</span>} | ||
{totalRecordCount <= 0 && <span>No results</span>} | ||
</div> | ||
<ButtonGroup className="me-2" aria-label="Date filter preset buttons"> | ||
<ButtonGroup className="me-2" aria-label="Pagination control buttons"> | ||
<Button | ||
variant="outline-primary" | ||
style={{ border: "none" }} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export const DEFAULT_QUERY_LIMIT = 50; | ||
export const MAX_RECORD_EXPORT_LIMIT = 1_000_000; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
import { useMemo } from "react"; | ||
import { gql } from "graphql-request"; | ||
|
||
import { produce } from "immer"; | ||
import { MAX_RECORD_EXPORT_LIMIT } from "./constants"; | ||
// todo: test quote escape | ||
|
||
const BASE_QUERY_STRING = ` | ||
|
@@ -181,6 +182,15 @@ export interface QueryConfig { | |
* managed by the advanced filter component | ||
*/ | ||
filterCards: FilterGroup[]; | ||
/** | ||
* Enables the export functionality | ||
*/ | ||
exportable?: boolean; | ||
/** | ||
* The name that will be given to the exported file, excluding | ||
* the file extension | ||
*/ | ||
exportFilename?: string; | ||
} | ||
|
||
/** | ||
|
@@ -416,4 +426,24 @@ export const useQueryBuilder = ( | |
): string => | ||
useMemo(() => { | ||
return buildQuery(queryConfig, contextFilters); | ||
|
||
}, [queryConfig, contextFilters]); | ||
|
||
/** | ||
* Hook which builds a graphql query for record exporting | ||
*/ | ||
export const useExportQuery = <T extends Record<string, unknown>>( | ||
queryConfig: QueryConfig, | ||
contextFilters?: Filter[] | ||
): string => { | ||
const newQueryConfig = useMemo(() => { | ||
// update the provided query with export settings | ||
return produce(queryConfig, (newQueryConfig) => { | ||
// reset limit and offset | ||
newQueryConfig.limit = MAX_RECORD_EXPORT_LIMIT; | ||
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. Hasura will accept a |
||
newQueryConfig.offset = 0; | ||
return newQueryConfig; | ||
}); | ||
}, [queryConfig]); | ||
return useQueryBuilder(newQueryConfig, contextFilters); | ||
}; |
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.
What would you think about including the time in addition to the date string in this default filename? Maybe
<table_name>-20250108-1532.csv
for something downloaded today, the 8th at 3:32PM local time?I know this bleeds slightly outside of the scope of this work here, as it's contained in that utility formatter, but bringing in that change allows for a user more easily keep multiple files downloaded in a set straight. I'm thinking of the use-case of when someone wants to compare multiple sets of crashes in some BI software or something like that. Being able to open up your file browser and easily sort the files in the order in which they were downloaded is valuable.
I guess put another way -- the filename is the only tiny bit of metadata we get to attach to these CSVs, so let's take as much advantage of that as we can, and avoid the browser's behavior that yields stuff like this:
some-name.csv
,some-name (1).csv
,some-name (2).csv
.