Skip to content

Commit

Permalink
[Issue #3518] download search results (#3689)
Browse files Browse the repository at this point in the history
* adds a button to the search page that downloads search results in csv format
* refactors fetch function service and helpers to be more flexible in order to allow a non-json response
* updates some flaky e2e tests
  • Loading branch information
doug-s-nava authored Feb 3, 2025
1 parent e63aa7d commit e475e7c
Show file tree
Hide file tree
Showing 31 changed files with 608 additions and 293 deletions.
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;
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) {
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);

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>(
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(
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

0 comments on commit e475e7c

Please sign in to comment.