From dcd9cbb9f1b5e9c0589af2874e9c181ef2ea75a4 Mon Sep 17 00:00:00 2001 From: John Clary Date: Fri, 3 Jan 2025 14:44:27 -0500 Subject: [PATCH 01/15] add hidden and exportable settings to columns --- app/types/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/types/types.ts b/app/types/types.ts index b9cf927ce..966da8e61 100644 --- a/app/types/types.ts +++ b/app/types/types.ts @@ -35,6 +35,14 @@ export interface ColDataCardDef> { */ relationship?: Relationship; sortable?: boolean; + /** + * If the column should be visibily hidden - does not affect record exporting + */ + hidden?: boolean; + /** + * if the column should be included in record exports + */ + exportable?: boolean; valueFormatter?: ( value: unknown, record: T, From 74c328ccc14ff8879c82137e3ca88846bb4ce4c1 Mon Sep 17 00:00:00 2001 From: John Clary Date: Fri, 3 Jan 2025 14:44:50 -0500 Subject: [PATCH 02/15] add exportable setting to q config and create useExportQuery hook --- app/utils/queryBuilder.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/app/utils/queryBuilder.ts b/app/utils/queryBuilder.ts index 9b015f340..dc1dd0f5f 100644 --- a/app/utils/queryBuilder.ts +++ b/app/utils/queryBuilder.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { gql } from "graphql-request"; - +import { produce } from "immer"; +import { ColDataCardDef } from "@/types/types"; // todo: test quote escape const BASE_QUERY_STRING = ` @@ -181,6 +182,10 @@ export interface QueryConfig { * managed by the advanced filter component */ filterCards: FilterGroup[]; + /** + * Enables the export functionality + */ + exportable?: boolean; } /** @@ -417,3 +422,27 @@ export const useQueryBuilder = ( useMemo(() => { return buildQuery(queryConfig, contextFilters); }, [queryConfig]); + +/** + * Hook which builds a graphql query for record exporting + */ +export const useExportQuery = >( + queryConfig: QueryConfig, + columns: ColDataCardDef[], + contextFilters?: Filter[] +): string => { + const newQueryConfig = useMemo(() => { + // update the provided query with export settings + return produce(queryConfig, (newQueryConfig) => { + // get exportable columns + newQueryConfig.columns = columns + .filter((col) => col.exportable) + .map((col) => col.path); + // reset limit and offset + newQueryConfig.limit = 1_000_000; + newQueryConfig.offset = 0; + return newQueryConfig; + }); + }, [queryConfig]); + return useQueryBuilder(newQueryConfig, contextFilters); +}; From 7e970c55c04159837e9996e599812d01e262d64b Mon Sep 17 00:00:00 2001 From: John Clary Date: Fri, 3 Jan 2025 14:45:37 -0500 Subject: [PATCH 03/15] add export button to TablePaginationControls --- app/components/TablePaginationControls.tsx | 34 ++++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/app/components/TablePaginationControls.tsx b/app/components/TablePaginationControls.tsx index bd54123a7..7dc69894c 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,39 @@ 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()} records`} + {exportable && ( + + )} + )} - {totalRecords <= 0 && No results} + {totalRecordCount <= 0 && No results}
- + + {downloadUrl && ( + + )} + {isLoading && ( + + )} + + + ); +} From 3bb09a54f7d1de6fcee5d8693e491301a1343148 Mon Sep 17 00:00:00 2001 From: John Clary Date: Fri, 3 Jan 2025 14:46:22 -0500 Subject: [PATCH 05/15] add export modal to tablewrapper --- app/components/TableWrapper.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/app/components/TableWrapper.tsx b/app/components/TableWrapper.tsx index 0325b3768..e07736fb5 100644 --- a/app/components/TableWrapper.tsx +++ b/app/components/TableWrapper.tsx @@ -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"; @@ -36,6 +42,7 @@ export default function TableWrapper>({ const [isFilterOpen, setIsFilterOpen] = useState(false); const [areFiltersDirty, setAreFiltersDirty] = useState(false); const [isLocalStorageLoaded, setIsLocalStorageLoaded] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); const [searchSettings, setSearchSettings] = useState({ searchString: String(initialQueryConfig.searchFilter.value), searchColumn: initialQueryConfig.searchFilter.column, @@ -45,6 +52,7 @@ export default function TableWrapper>({ }); const query = useQueryBuilder(queryConfig, contextFilters); + const exportQuery = useExportQuery(queryConfig, columns, contextFilters); const { data, aggregateData, isLoading, error } = useQuery({ // dont fire first query until localstorage is loaded @@ -170,7 +178,9 @@ export default function TableWrapper>({ setQueryConfig={setQueryConfig} recordCount={rows.length} isLoading={isLoading} - aggregateData={aggregateData} + totalRecordCount={aggregateData?.aggregate?.count || 0} + onClickDownload={() => setShowExportModal(true)} + exportable={Boolean(queryConfig.exportable)} /> @@ -200,6 +210,15 @@ export default function TableWrapper>({ /> + {queryConfig.exportable && ( + + onClose={() => setShowExportModal(false)} + query={exportQuery} + show={showExportModal} + totalRecordCount={aggregateData?.aggregate?.count || 0} + typename={queryConfig.tableName} + /> + )} ); } From 7be5b162d8a90401cd7b28d81269f5f1c6584485 Mon Sep 17 00:00:00 2001 From: John Clary Date: Fri, 3 Jan 2025 14:46:56 -0500 Subject: [PATCH 06/15] add export settings to crashes configs --- app/configs/crashesListViewColumns.tsx | 5 +++++ app/configs/crashesListViewTable.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/app/configs/crashesListViewColumns.tsx b/app/configs/crashesListViewColumns.tsx index c32977695..fd97760cc 100644 --- a/app/configs/crashesListViewColumns.tsx +++ b/app/configs/crashesListViewColumns.tsx @@ -8,6 +8,7 @@ export const crashesListViewColumns: ColDataCardDef[] = [ path: "record_locator", label: "Crash ID", sortable: true, + exportable: true, valueRenderer: (record: CrashesListRow) => ( {record.record_locator} @@ -18,21 +19,25 @@ export const crashesListViewColumns: ColDataCardDef[] = [ path: "case_id", label: "Case ID", sortable: true, + exportable: true, }, { path: "crash_timestamp", label: "Date", sortable: true, + exportable: true, valueFormatter: formatDate, }, { path: "address_primary", label: "Address", + exportable: true, sortable: true, }, { path: "collsn_desc", label: "Collision", + exportable: true, sortable: true, }, ]; diff --git a/app/configs/crashesListViewTable.ts b/app/configs/crashesListViewTable.ts index 5c53f607a..45b114099 100644 --- a/app/configs/crashesListViewTable.ts +++ b/app/configs/crashesListViewTable.ts @@ -252,4 +252,5 @@ export const crashesListViewQueryConfig: QueryConfig = { }), }, filterCards: crashesListViewfilterCards, + exportable: true, }; From 057bd9727e7a98c9e9a7ec6b224acc77526edc1c Mon Sep 17 00:00:00 2001 From: John Clary Date: Mon, 6 Jan 2025 13:52:35 -0500 Subject: [PATCH 07/15] rework export props and make more tables exportable --- app/components/TableExportModal.tsx | 26 ++++++++++++++++++++-- app/components/TablePaginationControls.tsx | 4 +++- app/components/TableWrapper.tsx | 1 + app/configs/crashesListViewColumns.tsx | 5 ----- app/configs/crashesListViewTable.ts | 3 ++- app/configs/locationCrashesTable.ts | 2 ++ app/configs/locationsListViewTable.ts | 2 ++ app/types/types.ts | 8 ------- app/utils/queryBuilder.ts | 9 ++++---- 9 files changed, 39 insertions(+), 21 deletions(-) diff --git a/app/components/TableExportModal.tsx b/app/components/TableExportModal.tsx index d28b24ba0..68d37ada6 100644 --- a/app/components/TableExportModal.tsx +++ b/app/components/TableExportModal.tsx @@ -7,8 +7,20 @@ 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 @@ -36,6 +48,7 @@ interface TableExportModalProps { * UI component which provides a CSV download of the provided query */ export default function TableExportModal>({ + exportFilename, onClose, query, totalRecordCount, @@ -85,7 +98,12 @@ export default function TableExportModal>({ className="d-flex justify-content-between align-items-center" > -
{`You are about to download ${totalRecordCount.toLocaleString()} records — this may take a few minutes for larger downloads`}
+
+ You are about to download{" "} + {`${totalRecordCount.toLocaleString()} `} + {`record${totalRecordCount === 1 ? "" : "s"}`} — this + may take a few minutes for larger downloads +
@@ -93,7 +111,11 @@ export default function TableExportModal>({ Cancel {downloadUrl && ( - )} + {error && ( + +

Something went wrong

+

+

+ Error + {String(error)} +
+

+
+ )} {isLoading && (