Skip to content
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

Open
wants to merge 16 commits into
base: john/20202-page-and-record-counts
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion app/app/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default function Users() {
</div>
{/* todo: standardize the way we show error messages and use error boundary */}
{Boolean(error) && (
<Alert variant="dange">
<Alert variant="danger">
<p>Something went wrong</p>
<p>
<details>
Expand Down
151 changes: 151 additions & 0 deletions app/components/TableExportModal.tsx
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`;
Copy link
Member

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.


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
Copy link
Member

Choose a reason for hiding this comment

The 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 useState, can we do something like a check-mark that has some spinner-like motion, to indicate that something intended and/or positive has happened. If you look in our slack at the :checked: emoji .. one loop of that is what I'm trying to describe as a good indicator of this type of thing.

<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>
);
}
36 changes: 27 additions & 9 deletions app/components/TablePaginationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }}
Copy link
Contributor

@mateoclarke mateoclarke Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels weird to me. To use the outline-primary variant but then override the outline/border entirely. I can see this being pretty fickle and inconsistent over time. So my question/recommendation is can we write a reusable CSS class that would be consistent to apply as an override and as a 1-liner?

Aside from that, why use style here when bootstrap has good utilities? It doesn't make a big difference but I'd use class="border-0" in this case. Inline style are bad is a dogma I've inherited. Not sure how well it serves us here.

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" }}
Expand Down
24 changes: 22 additions & 2 deletions app/components/TableWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ import Table from "@/components/Table";
import TableSearch, { SearchSettings } from "@/components/TableSearch";
import TableDateSelector from "@/components/TableDateSelector";
import TableSearchFieldSelector from "@/components/TableSearchFieldSelector";
import { QueryConfig, useQueryBuilder, Filter } from "@/utils/queryBuilder";
import {
QueryConfig,
useQueryBuilder,
Filter,
useExportQuery,
} from "@/utils/queryBuilder";
import { ColDataCardDef } from "@/types/types";
import TableAdvancedSearchFilterMenu from "@/components/TableAdvancedSearchFilterMenu";
import TableAdvancedSearchFilterToggle from "@/components/TableAdvancedSearchFilterToggle";
import TableExportModal from "@/components/TableExportModal";
import TablePaginationControls from "@/components/TablePaginationControls";
import { useActiveSwitchFilterCount } from "@/components/TableAdvancedSearchFilterToggle";
import TableResetFiltersToggle from "@/components/TableResetFiltersToggle";
Expand Down Expand Up @@ -54,6 +60,7 @@ export default function TableWrapper<T extends Record<string, unknown>>({
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [areFiltersDirty, setAreFiltersDirty] = useState(false);
const [isLocalStorageLoaded, setIsLocalStorageLoaded] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [searchSettings, setSearchSettings] = useState<SearchSettings>({
searchString: String(initialQueryConfig.searchFilter.value),
searchColumn: initialQueryConfig.searchFilter.column,
Expand All @@ -63,6 +70,7 @@ export default function TableWrapper<T extends Record<string, unknown>>({
});

const query = useQueryBuilder(queryConfig, contextFilters);
const exportQuery = useExportQuery(queryConfig, contextFilters);

const { data, aggregateData, isLoading, error, refetch } = useQuery<T>({
// dont fire first query until localstorage is loaded
Expand Down Expand Up @@ -194,7 +202,9 @@ export default function TableWrapper<T extends Record<string, unknown>>({
setQueryConfig={setQueryConfig}
recordCount={rows.length}
isLoading={isLoading}
aggregateData={aggregateData}
totalRecordCount={aggregateData?.aggregate?.count || 0}
onClickDownload={() => setShowExportModal(true)}
exportable={Boolean(queryConfig.exportable)}
/>
</Col>
</Row>
Expand Down Expand Up @@ -224,6 +234,16 @@ export default function TableWrapper<T extends Record<string, unknown>>({
/>
</Col>
</Row>
{queryConfig.exportable && (
<TableExportModal<T>
exportFilename={queryConfig.exportFilename}
onClose={() => setShowExportModal(false)}
query={exportQuery}
show={showExportModal}
totalRecordCount={aggregateData?.aggregate?.count || 0}
typename={queryConfig.tableName}
/>
)}
</>
);
}
7 changes: 5 additions & 2 deletions app/configs/crashesListViewTable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getStartOfYearDate,
getYearsAgoDate,
makeDateFilters,
} from "@/components/TableDateSelector";
import { crashesListViewColumns } from "@/configs/crashesListViewColumns";
Expand Down Expand Up @@ -226,6 +227,8 @@ const crashesListViewfilterCards: FilterGroup[] = [

export const crashesListViewQueryConfig: QueryConfig = {
columns,
exportable: true,
exportFilename: "crashes",
tableName: "crashes_list_view",
limit: DEFAULT_QUERY_LIMIT,
offset: 0,
Expand All @@ -244,10 +247,10 @@ export const crashesListViewQueryConfig: QueryConfig = {
{ label: "Address", value: "address_primary" },
],
dateFilter: {
mode: "ytd",
mode: "1y",
column: "crash_timestamp",
filters: makeDateFilters("crash_timestamp", {
start: getStartOfYearDate(),
start: getYearsAgoDate(1),
end: null,
}),
},
Expand Down
2 changes: 2 additions & 0 deletions app/configs/locationCrashesTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const locationCrashesFiltercards: FilterGroup[] = [

export const locationCrashesQueryConfig: QueryConfig = {
columns,
exportable: true,
exportFilename: "location-crashes",
tableName: "location_crashes_view",
limit: DEFAULT_QUERY_LIMIT,
offset: 0,
Expand Down
2 changes: 2 additions & 0 deletions app/configs/locationsListViewTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const locationsListViewFiltercards: FilterGroup[] = [

export const locationsListViewQueryConfig: QueryConfig = {
columns,
exportable: true,
exportFilename: "locations",
tableName: "locations_list_view",
limit: DEFAULT_QUERY_LIMIT,
offset: 0,
Expand Down
1 change: 1 addition & 0 deletions app/utils/constants.ts
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;
32 changes: 31 additions & 1 deletion app/utils/queryBuilder.ts
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 = `
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hasura will accept a limit of null, but modifying this typing to accept null was a headache i didn't really want to deal with 🤷

newQueryConfig.offset = 0;
return newQueryConfig;
});
}, [queryConfig]);
return useQueryBuilder(newQueryConfig, contextFilters);
};