-
Notifications
You must be signed in to change notification settings - Fork 22
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
[Issue #3518] download search results #3689
Changes from all commits
8669ff8
7900244
cdad8c0
557cb87
3202931
061fe14
bb0ec06
109dcce
e33b130
3080d15
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,36 @@ | ||
import { downloadOpportunities } from "src/services/fetch/fetchers/searchFetcher"; | ||
import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes"; | ||
|
||
import { NextRequest, NextResponse } from "next/server"; | ||
|
||
export const revalidate = 0; | ||
|
||
/* | ||
the data flow here goes like: | ||
|
||
ExportSearchResultsButton click -> | ||
/export route -> | ||
downloadOpportunities -> | ||
fetchOpportunitySearch -> | ||
ExportSearchResultsButton (handle response by blobbing it to the location) -> user's file system | ||
|
||
*/ | ||
|
||
export async function GET(request: NextRequest) { | ||
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. making API request here in order to avoid exposing the API key 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. Seems like a shame we have to do this. Maybe at some point we can open up some of the APIs for the general public. Noting the API is 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. Agree it's not great, but the best option we have for now. We should maybe talk as a team about how we want to handle this sort of thing. As far as the GET v POST, I'd be fine to make the first request as POST as well, but since it's largely internal and not sending a request body, a GET seemed fine. The search itself being a post makes sense to me since we're sending data in the body of the request. |
||
try { | ||
const searchParams = convertSearchParamsToProperTypes( | ||
Object.fromEntries(request.nextUrl.searchParams.entries().toArray()), | ||
); | ||
const apiResponseBody = await downloadOpportunities(searchParams); | ||
return new NextResponse(apiResponseBody, { | ||
headers: { | ||
"Content-Type": "text/csv", | ||
"Content-Disposition": | ||
"attachment; filename=simpler-grants-search-results.csv", | ||
}, | ||
}); | ||
} catch (e) { | ||
console.error("Error downloading search results", e); | ||
throw e; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,4 @@ | ||
import dayjs from "dayjs"; | ||
import advancedFormat from "dayjs/plugin/advancedFormat"; | ||
import timezone from "dayjs/plugin/timezone"; | ||
import { getConfiguredDayJs } from "src/utils/dateUtil"; | ||
|
||
import { useTranslations } from "next-intl"; | ||
import { Link, Table } from "@trussworks/react-uswds"; | ||
|
@@ -16,9 +14,6 @@ interface OpportunityDocumentsProps { | |
documents: OpportunityDocument[]; | ||
} | ||
|
||
dayjs.extend(advancedFormat); | ||
dayjs.extend(timezone); | ||
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. moved this into a central location |
||
|
||
const DocumentTable = ({ documents }: OpportunityDocumentsProps) => { | ||
const t = useTranslations("OpportunityListing.documents"); | ||
|
||
|
@@ -47,7 +42,9 @@ const DocumentTable = ({ documents }: OpportunityDocumentsProps) => { | |
</td> | ||
<td data-label={t("table_col_last_updated")}> | ||
{/* https://day.js.org/docs/en/display/format */} | ||
{dayjs(document.updated_at).format("MMM D, YYYY hh:mm A z")} | ||
{getConfiguredDayJs()(document.updated_at).format( | ||
"MMM D, YYYY hh:mm A z", | ||
)} | ||
</td> | ||
</tr> | ||
))} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
"use client"; | ||
|
||
import { downloadSearchResultsCSV } from "src/services/fetch/fetchers/clientSearchResultsDownloadFetcher"; | ||
|
||
import { useTranslations } from "next-intl"; | ||
import { useSearchParams } from "next/navigation"; | ||
import { useCallback } from "react"; | ||
import { Button } from "@trussworks/react-uswds"; | ||
|
||
import { USWDSIcon } from "src/components/USWDSIcon"; | ||
|
||
export function ExportSearchResultsButton() { | ||
const t = useTranslations("Search.exportButton"); | ||
const searchParams = useSearchParams(); | ||
|
||
const downloadSearchResults = useCallback(() => { | ||
// catch included here to satisfy linter | ||
downloadSearchResultsCSV(searchParams).catch((e) => { | ||
throw e; | ||
}); | ||
}, [searchParams]); | ||
|
||
return ( | ||
<div | ||
className="desktop:grid-col-4 desktop:display-flex flex-align-self-center" | ||
data-testid="search-download-button-container" | ||
> | ||
<Button | ||
outline={true} | ||
type={"submit"} | ||
className="width-auto margin-top-2 tablet:width-100 tablet-lg:margin-top-0" | ||
onClick={downloadSearchResults} | ||
> | ||
<USWDSIcon name="file_download" className="usa-icon--size-3" /> | ||
{t("title")} | ||
</Button> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
import "server-only"; | ||
|
||
import { compact, isEmpty } from "lodash"; | ||
import { compact } from "lodash"; | ||
import { environment } from "src/constants/environments"; | ||
import { | ||
ApiRequestError, | ||
|
@@ -27,7 +27,6 @@ export interface HeadersDict { | |
} | ||
|
||
// Configuration of headers to send with all requests | ||
// Can include feature flags in child classes | ||
export function getDefaultHeaders(): HeadersDict { | ||
const headers: HeadersDict = {}; | ||
|
||
|
@@ -38,32 +37,6 @@ export function getDefaultHeaders(): HeadersDict { | |
return headers; | ||
} | ||
|
||
/** | ||
* Send a request and handle the response | ||
* @param queryParamData: note that this is only used in error handling in order to help restore original page state | ||
*/ | ||
export async function sendRequest<ResponseType extends APIResponse>( | ||
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 this function wasn't doing much, and what it was doing (reading a json response body) was making our implementation less flexible, I got rid of it and moved all this functionality either into the fetcher generator or endpoint specific wrappers |
||
url: string, | ||
fetchOptions: RequestInit, | ||
queryParamData?: QueryParamData, | ||
): Promise<ResponseType> { | ||
let response; | ||
let responseBody; | ||
try { | ||
response = await fetch(url, fetchOptions); | ||
responseBody = (await response.json()) as ResponseType; | ||
} catch (error) { | ||
// API most likely down, but also possibly an error setting up or sending a request | ||
// or parsing the response. | ||
throw fetchErrorToNetworkError(error, queryParamData); | ||
} | ||
if (!response.ok) { | ||
handleNotOkResponse(responseBody, url, queryParamData); | ||
} | ||
|
||
return responseBody; | ||
} | ||
|
||
export function createRequestUrl( | ||
method: ApiMethod, | ||
basePath: string, | ||
|
@@ -115,7 +88,7 @@ export function createRequestBody( | |
/** | ||
* Handle request errors | ||
*/ | ||
function fetchErrorToNetworkError( | ||
export function fetchErrorToNetworkError( | ||
error: unknown, | ||
searchInputs?: QueryParamData, | ||
) { | ||
|
@@ -127,42 +100,12 @@ function fetchErrorToNetworkError( | |
: new NetworkError(error); | ||
} | ||
|
||
// note that this will pass along filter inputs in order to maintain the state | ||
// of the page when relaying an error, but anything passed in the body of the request, | ||
// such as keyword search query will not be included | ||
function handleNotOkResponse( | ||
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. similar to the above, this wasn't doing much, moved the behavior into the fetcher generator |
||
response: APIResponse, | ||
url: string, | ||
searchInputs?: QueryParamData, | ||
) { | ||
const { errors } = response; | ||
if (isEmpty(errors)) { | ||
// No detailed errors provided, throw generic error based on status code | ||
throwError(response, url, searchInputs); | ||
} else { | ||
if (errors) { | ||
const firstError = errors[0]; | ||
throwError(response, url, searchInputs, firstError); | ||
} | ||
} | ||
} | ||
export const throwError = (responseBody: APIResponse, url: string) => { | ||
const { status_code = 0, message = "", errors } = responseBody; | ||
console.error(`API request error at ${url} (${status_code}): ${message}`); | ||
|
||
export const throwError = ( | ||
response: APIResponse, | ||
url: string, | ||
searchInputs?: QueryParamData, | ||
firstError?: unknown, | ||
) => { | ||
const { status_code = 0, message = "" } = response; | ||
console.error( | ||
`API request error at ${url} (${status_code}): ${message}`, | ||
searchInputs, | ||
); | ||
const details = (errors && errors[0]) || {}; | ||
|
||
const details = { | ||
searchInputs, | ||
...(firstError || {}), | ||
}; | ||
switch (status_code) { | ||
case 400: | ||
throw new BadRequestError(message, details); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import { getConfiguredDayJs } from "src/utils/dateUtil"; | ||
|
||
import { ReadonlyURLSearchParams } from "next/navigation"; | ||
|
||
// downloads csv, then blobs it out to allow browser to download it | ||
// note that this could be handled by just pointing the browser location at the URL | ||
// but we'd lose any ability for graceful error handling that way | ||
export const downloadSearchResultsCSV = async ( | ||
searchParams: ReadonlyURLSearchParams, | ||
) => { | ||
try { | ||
const response = await fetch( | ||
`/api/search/export?${searchParams.toString()}`, | ||
); | ||
|
||
if (!response.ok) { | ||
throw new Error(`Unsuccessful csv download. ${response.status}`); | ||
} | ||
const csvBlob = await response.blob(); | ||
location.assign( | ||
URL.createObjectURL( | ||
new File( | ||
[csvBlob], | ||
`grants-search-${getConfiguredDayJs()(new Date()).format("YYYYMMDDHHmm")}.csv`, | ||
{ | ||
type: "data:text/csv", | ||
}, | ||
), | ||
), | ||
); | ||
} catch (e) { | ||
console.error(e); | ||
} | ||
}; |
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.
just a rename to reflect that this type is generic rather than specific in any way