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

[Issue #3518] download search results #3689

Merged
merged 10 commits into from
Feb 3, 2025
8 changes: 3 additions & 5 deletions frontend/src/app/[locale]/opportunity/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import NotFound from "src/app/[locale]/not-found";
import { OPPORTUNITY_CRUMBS } from "src/constants/breadcrumbs";
import { ApiRequestError, parseErrorStatus } from "src/errors";
import withFeatureFlag from "src/hoc/withFeatureFlag";
import { fetchOpportunity } from "src/services/fetch/fetchers/fetchers";
import { getOpportunityDetails } from "src/services/fetch/fetchers/opportunityFetcher";
import { Opportunity } from "src/types/opportunity/opportunityResponseTypes";
import { WithFeatureFlagProps } from "src/types/uiTypes";

Expand Down Expand Up @@ -38,9 +38,7 @@ export async function generateMetadata({
const t = await getTranslations({ locale });
let title = `${t("OpportunityListing.page_title")}`;
try {
const { data: opportunityData } = await fetchOpportunity({
subPath: id,
});
const { data: opportunityData } = await getOpportunityDetails(id);
title = `${t("OpportunityListing.page_title")} - ${opportunityData.opportunity_title}`;
} catch (error) {
console.error("Failed to render page title due to API error", error);
Expand Down Expand Up @@ -106,7 +104,7 @@ async function OpportunityListing({ params }: OpportunityListingProps) {

let opportunityData = {} as Opportunity;
try {
const response = await fetchOpportunity({ subPath: id });
const response = await getOpportunityDetails(id);
opportunityData = response.data;
} catch (error) {
if (parseErrorStatus(error as ApiRequestError) === 404) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/[locale]/search/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import QueryProvider from "src/app/[locale]/search/QueryProvider";
import { usePrevious } from "src/hooks/usePrevious";
import { FrontendErrorDetails } from "src/types/apiResponseTypes";
import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
import { OptionalStringDict } from "src/types/searchRequestURLTypes";
import { Breakpoints, ErrorProps } from "src/types/uiTypes";
import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes";

Expand All @@ -19,7 +19,7 @@ import ServerErrorAlert from "src/components/ServerErrorAlert";

export interface ParsedError {
message: string;
searchInputs: ServerSideSearchParams;
searchInputs: OptionalStringDict;
Copy link
Collaborator Author

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

status: number;
type: string;
details?: FrontendErrorDetails;
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/app/api/search/export/route.ts
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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

making API request here in order to avoid exposing the API key

Copy link
Collaborator

Choose a reason for hiding this comment

The 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 POST while we're using a GET, I think GET is the correct interpretation for search since a search request isn't meaningfully creating or updating server entities.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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;
}
}
11 changes: 4 additions & 7 deletions frontend/src/components/opportunity/OpportunityDocuments.tsx
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";
Expand All @@ -16,9 +14,6 @@ interface OpportunityDocumentsProps {
documents: OpportunityDocument[];
}

dayjs.extend(advancedFormat);
dayjs.extend(timezone);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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");

Expand Down Expand Up @@ -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>
))}
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/search/ExportSearchResultsButton.tsx
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>
);
}
7 changes: 6 additions & 1 deletion frontend/src/components/search/SearchPagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ export default function SearchPagination({
const pageCount = totalPages || Number(totalPagesFromQuery);

return (
<div className={`grants-pagination ${loading ? "disabled" : ""}`}>
<div
className={
"desktop:grid-col-fill desktop:display-flex flex-justify-center"
}
>
{totalResults !== "0" && pageCount > 0 && (
<Pagination
className={`grants-pagination padding-top-2 border-top-1px border-base tablet-lg:padding-top-0 tablet-lg:border-top-0 ${loading ? "disabled" : ""}`}
aria-disabled={loading}
pathname="/search"
totalPages={pageCount}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/search/SearchPaginationFetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface SearchPaginationProps {
searchResultsPromise: Promise<SearchAPIResponse>;
// Determines whether clicking on pager items causes a scroll to the top of the search
// results. Created so the bottom pager can scroll.
scroll: boolean;
scroll?: boolean;
page: number;
query?: string | null;
}
Expand All @@ -17,7 +17,7 @@ export default async function SearchPaginationFetch({
page,
query,
searchResultsPromise,
scroll,
scroll = false,
}: SearchPaginationProps) {
const searchResults = await searchResultsPromise;
const totalPages = searchResults.pagination_info?.total_pages;
Expand Down
29 changes: 16 additions & 13 deletions frontend/src/components/search/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SearchPaginationFetch from "src/components/search/SearchPaginationFetch";
import SearchResultsHeader from "src/components/search/SearchResultsHeader";
import SearchResultsHeaderFetch from "src/components/search/SearchResultsHeaderFetch";
import SearchResultsListFetch from "src/components/search/SearchResultsListFetch";
import { ExportSearchResultsButton } from "./ExportSearchResultsButton";

export default function SearchResults({
searchParams,
Expand Down Expand Up @@ -36,19 +37,21 @@ export default function SearchResults({
/>
</Suspense>
<div className="usa-prose">
<Suspense
key={pager1key}
fallback={
<SearchPagination loading={true} page={page} query={query} />
}
>
<SearchPaginationFetch
page={page}
query={query}
searchResultsPromise={searchResultsPromise}
scroll={false}
/>
</Suspense>
<div className="tablet-lg:display-flex">
<ExportSearchResultsButton />
<Suspense
key={pager1key}
fallback={
<SearchPagination loading={true} page={page} query={query} />
}
>
<SearchPaginationFetch
page={page}
query={query}
searchResultsPromise={searchResultsPromise}
/>
</Suspense>
</div>
<Suspense key={key} fallback={<Loading message={loadingMessage} />}>
<SearchResultsListFetch searchResultsPromise={searchResultsPromise} />
</Suspense>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/messages/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,9 @@ export const messages = {
generic_error_cta: "Please try your search again.",
validationError: "Search Validation Error",
tooLongError: "Search terms must be no longer than 100 characters.",
exportButton: {
title: "Export results",
},
},
Maintenance: {
heading: "Simpler.Grants.gov Is Currently Undergoing Maintenance",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/services/featureFlags/FeatureFlagManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
parseFeatureFlagsFromString,
setCookie,
} from "src/services/featureFlags/featureFlagHelpers";
import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
import { OptionalStringDict } from "src/types/searchRequestURLTypes";

import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { NextRequest, NextResponse } from "next/server";
Expand Down Expand Up @@ -84,7 +84,7 @@ export class FeatureFlagsManager {
isFeatureEnabled(
name: string,
cookies: NextRequest["cookies"] | ReadonlyRequestCookies,
searchParams?: ServerSideSearchParams,
searchParams?: OptionalStringDict,
): boolean {
if (!isValidFeatureFlag(name)) {
throw new Error(`\`${name}\` is not a valid feature flag`);
Expand Down
69 changes: 6 additions & 63 deletions frontend/src/services/fetch/fetcherHelpers.ts
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,
Expand All @@ -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 = {};

Expand All @@ -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>(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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,
Expand Down Expand Up @@ -115,7 +88,7 @@ export function createRequestBody(
/**
* Handle request errors
*/
function fetchErrorToNetworkError(
export function fetchErrorToNetworkError(
error: unknown,
searchInputs?: QueryParamData,
) {
Expand All @@ -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(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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);
Expand Down
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);
}
};
Loading