diff --git a/app/app/users/page.tsx b/app/app/users/page.tsx index 48242325d..86a882c68 100644 --- a/app/app/users/page.tsx +++ b/app/app/users/page.tsx @@ -73,7 +73,7 @@ export default function Users() { {/* todo: standardize the way we show error messages and use error boundary */} {Boolean(error) && ( - +

Something went wrong

diff --git a/app/components/TableExportModal.tsx b/app/components/TableExportModal.tsx new file mode 100644 index 000000000..e6af03491 --- /dev/null +++ b/app/components/TableExportModal.tsx @@ -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>({ + 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(null); + const { data, isLoading, error } = useQuery({ + // 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 ( + + + Download data + + + + +
+ You are about to download{" "} + {`${totalRecordCount.toLocaleString()} `} + {`record${totalRecordCount === 1 ? "" : "s"}`} — this + may take a few minutes for larger downloads +
+
+
+ + + {downloadUrl && ( + + )} + {error && ( + +

Something went wrong

+

+

+ Error + {String(error)} +
+

+
+ )} + {isLoading && ( + + )} +
+
+ ); +} diff --git a/app/components/TablePaginationControls.tsx b/app/components/TablePaginationControls.tsx index bd54123a7..35b0e7ee6 100644 --- a/app/components/TablePaginationControls.tsx +++ b/app/components/TablePaginationControls.tsx @@ -6,15 +6,16 @@ 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>; recordCount: number; + totalRecordCount: number; isLoading: boolean; - aggregateData?: HasuraAggregateData; + onClickDownload: () => void; + exportable: boolean; } /** @@ -22,24 +23,41 @@ interface PaginationControlProps { * 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 (
- {totalRecords > 0 && ( - {`${totalRecords.toLocaleString()} records`} + {totalRecordCount > 0 && ( + <> + {`${totalRecordCount.toLocaleString()} record${ + totalRecordCount === 1 ? "" : "s" + }`} + {exportable && ( + + )} + )} - {totalRecords <= 0 && No results} + {totalRecordCount <= 0 && No results}
- +