From e475e7c9005f65497677b48129669f2ed8467c33 Mon Sep 17 00:00:00 2001 From: doug-s-nava <92806979+doug-s-nava@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:17:11 -0500 Subject: [PATCH] [Issue #3518] download search results (#3689) * 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 --- .../app/[locale]/opportunity/[id]/page.tsx | 8 +- frontend/src/app/[locale]/search/error.tsx | 4 +- frontend/src/app/api/search/export/route.ts | 36 +++++ .../opportunity/OpportunityDocuments.tsx | 11 +- .../search/ExportSearchResultsButton.tsx | 39 ++++++ .../components/search/SearchPagination.tsx | 7 +- .../search/SearchPaginationFetch.tsx | 4 +- .../src/components/search/SearchResults.tsx | 29 ++-- frontend/src/i18n/messages/en/index.ts | 3 + .../featureFlags/FeatureFlagManager.ts | 4 +- frontend/src/services/fetch/fetcherHelpers.ts | 69 +--------- .../clientSearchResultsDownloadFetcher.ts | 34 +++++ .../src/services/fetch/fetchers/fetchers.ts | 60 +++++--- .../fetch/fetchers/opportunityFetcher.ts | 11 ++ .../services/fetch/fetchers/searchFetcher.ts | 46 ++++++- .../src/types/search/searchRequestTypes.ts | 1 + frontend/src/types/searchRequestURLTypes.ts | 2 +- frontend/src/types/uiTypes.ts | 4 +- frontend/src/utils/dateUtil.ts | 9 ++ .../convertSearchParamsToProperTypes.ts | 4 +- .../api/auth/search/export/route.test.ts | 58 ++++++++ frontend/tests/api/auth/session/route.test.ts | 2 +- .../search/ExportSearchResultsButton.test.tsx | 39 ++++++ .../tests/e2e/search/search-download.spec.ts | 19 +++ .../tests/e2e/search/search-loading.spec.ts | 4 +- frontend/tests/e2e/search/search.spec.ts | 8 +- .../services/fetch/FetcherHelpers.test.ts | 129 ++---------------- .../tests/services/fetch/Fetchers.test.ts | 94 ++++++++----- .../fetch/fetchers/SearchFetcher.test.ts | 59 ++++++-- ...clientSearchResultsDownloadFetcher.test.ts | 76 +++++++++++ .../fetch/fetchers/opportunityFetcher.test.ts | 28 ++++ 31 files changed, 608 insertions(+), 293 deletions(-) create mode 100644 frontend/src/app/api/search/export/route.ts create mode 100644 frontend/src/components/search/ExportSearchResultsButton.tsx create mode 100644 frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts create mode 100644 frontend/src/services/fetch/fetchers/opportunityFetcher.ts create mode 100644 frontend/tests/api/auth/search/export/route.test.ts create mode 100644 frontend/tests/components/search/ExportSearchResultsButton.test.tsx create mode 100644 frontend/tests/e2e/search/search-download.spec.ts create mode 100644 frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts create mode 100644 frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts diff --git a/frontend/src/app/[locale]/opportunity/[id]/page.tsx b/frontend/src/app/[locale]/opportunity/[id]/page.tsx index c4d62acee..1afaf343a 100644 --- a/frontend/src/app/[locale]/opportunity/[id]/page.tsx +++ b/frontend/src/app/[locale]/opportunity/[id]/page.tsx @@ -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"; @@ -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); @@ -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) { diff --git a/frontend/src/app/[locale]/search/error.tsx b/frontend/src/app/[locale]/search/error.tsx index 261eebea7..be38ec2e1 100644 --- a/frontend/src/app/[locale]/search/error.tsx +++ b/frontend/src/app/[locale]/search/error.tsx @@ -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"; @@ -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; diff --git a/frontend/src/app/api/search/export/route.ts b/frontend/src/app/api/search/export/route.ts new file mode 100644 index 000000000..5dfeca1df --- /dev/null +++ b/frontend/src/app/api/search/export/route.ts @@ -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; + } +} diff --git a/frontend/src/components/opportunity/OpportunityDocuments.tsx b/frontend/src/components/opportunity/OpportunityDocuments.tsx index ecf03d7a4..ef87bb6b3 100644 --- a/frontend/src/components/opportunity/OpportunityDocuments.tsx +++ b/frontend/src/components/opportunity/OpportunityDocuments.tsx @@ -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); - const DocumentTable = ({ documents }: OpportunityDocumentsProps) => { const t = useTranslations("OpportunityListing.documents"); @@ -47,7 +42,9 @@ const DocumentTable = ({ documents }: OpportunityDocumentsProps) => { {/* 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", + )} ))} diff --git a/frontend/src/components/search/ExportSearchResultsButton.tsx b/frontend/src/components/search/ExportSearchResultsButton.tsx new file mode 100644 index 000000000..c7ebc3f4b --- /dev/null +++ b/frontend/src/components/search/ExportSearchResultsButton.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/src/components/search/SearchPagination.tsx b/frontend/src/components/search/SearchPagination.tsx index 4c5e45637..30c793650 100644 --- a/frontend/src/components/search/SearchPagination.tsx +++ b/frontend/src/components/search/SearchPagination.tsx @@ -63,9 +63,14 @@ export default function SearchPagination({ const pageCount = totalPages || Number(totalPagesFromQuery); return ( -
+
{totalResults !== "0" && pageCount > 0 && ( ; // 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; } @@ -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; diff --git a/frontend/src/components/search/SearchResults.tsx b/frontend/src/components/search/SearchResults.tsx index bb688c9a3..fd8c1ae0c 100644 --- a/frontend/src/components/search/SearchResults.tsx +++ b/frontend/src/components/search/SearchResults.tsx @@ -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, @@ -36,19 +37,21 @@ export default function SearchResults({ />
- - } - > - - +
+ + + } + > + + +
}> diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index 3f40de6db..ce238f204 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -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", diff --git a/frontend/src/services/featureFlags/FeatureFlagManager.ts b/frontend/src/services/featureFlags/FeatureFlagManager.ts index 11c9baa7b..27ce3c188 100644 --- a/frontend/src/services/featureFlags/FeatureFlagManager.ts +++ b/frontend/src/services/featureFlags/FeatureFlagManager.ts @@ -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"; @@ -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`); diff --git a/frontend/src/services/fetch/fetcherHelpers.ts b/frontend/src/services/fetch/fetcherHelpers.ts index 3f863ef1c..5419fd6bd 100644 --- a/frontend/src/services/fetch/fetcherHelpers.ts +++ b/frontend/src/services/fetch/fetcherHelpers.ts @@ -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( - url: string, - fetchOptions: RequestInit, - queryParamData?: QueryParamData, -): Promise { - 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( - 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); diff --git a/frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts b/frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts new file mode 100644 index 000000000..dbe05fa51 --- /dev/null +++ b/frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts @@ -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); + } +}; diff --git a/frontend/src/services/fetch/fetchers/fetchers.ts b/frontend/src/services/fetch/fetchers/fetchers.ts index 36c9fe574..8cfad085a 100644 --- a/frontend/src/services/fetch/fetchers/fetchers.ts +++ b/frontend/src/services/fetch/fetchers/fetchers.ts @@ -1,5 +1,6 @@ import "server-only"; +import { ApiRequestError } from "src/errors"; import { EndpointConfig, fetchOpportunityEndpoint, @@ -9,22 +10,20 @@ import { import { createRequestBody, createRequestUrl, + fetchErrorToNetworkError, getDefaultHeaders, HeadersDict, JSONRequestBody, - sendRequest, + throwError, } from "src/services/fetch/fetcherHelpers"; import { APIResponse } from "src/types/apiResponseTypes"; -import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes"; -import { QueryParamData } from "src/types/search/searchRequestTypes"; -import { SearchAPIResponse } from "src/types/search/searchResponseTypes"; import { cache } from "react"; // returns a function which can be used to make a request to an endpoint defined in the passed config // note that subpath is dynamic per request, any static paths at this point would need to be included in the namespace -// making this more flexible is a future todo -export function requesterForEndpoint({ +// returns the full api response, dealing with parsing the body will happen explicitly for each request type +export function requesterForEndpoint({ method, basePath, version, @@ -33,12 +32,11 @@ export function requesterForEndpoint({ return async function ( options: { subPath?: string; - queryParamData?: QueryParamData; // only used for error handling purposes body?: JSONRequestBody; additionalHeaders?: HeadersDict; } = {}, - ): Promise { - const { additionalHeaders = {}, body, queryParamData, subPath } = options; + ): Promise { + const { additionalHeaders = {}, body, subPath } = options; const url = createRequestUrl( method, basePath, @@ -52,27 +50,51 @@ export function requesterForEndpoint({ ...additionalHeaders, }; - const response = await sendRequest( - url, - { + let response; + try { + response = await fetch(url, { body: method === "GET" || !body ? null : createRequestBody(body), headers, method, - }, - queryParamData, - ); + }); + } catch (error) { + // API most likely down, but also possibly an error setting up or sending a request + // or parsing the response. + throw fetchErrorToNetworkError(error); + } + + if ( + !response.ok && + response.headers.get("Content-Type") === "application/json" + ) { + // we can assume this is serializable json based on the response header, but we'll catch anyway + let jsonBody; + try { + jsonBody = (await response.json()) as APIResponse; + } catch (e) { + throw new Error( + `bad Json from error response at ${url} with status code ${response.status}`, + ); + } + return throwError(jsonBody, url); + } else if (!response.ok) { + throw new ApiRequestError( + `unable to fetch ${url}`, + "APIRequestError", + response.status, + ); + } return response; }; } export const fetchOpportunity = cache( - requesterForEndpoint(fetchOpportunityEndpoint), + requesterForEndpoint(fetchOpportunityEndpoint), ); -export const fetchOpportunitySearch = requesterForEndpoint( +export const fetchOpportunitySearch = requesterForEndpoint( opportunitySearchEndpoint, ); -export const postUserLogout = - requesterForEndpoint(userLogoutEndpoint); +export const postUserLogout = requesterForEndpoint(userLogoutEndpoint); diff --git a/frontend/src/services/fetch/fetchers/opportunityFetcher.ts b/frontend/src/services/fetch/fetchers/opportunityFetcher.ts new file mode 100644 index 000000000..f25a797a4 --- /dev/null +++ b/frontend/src/services/fetch/fetchers/opportunityFetcher.ts @@ -0,0 +1,11 @@ +import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes"; + +import { fetchOpportunity } from "./fetchers"; + +export const getOpportunityDetails = async ( + id: string, +): Promise => { + const response = await fetchOpportunity({ subPath: id }); + const responseBody = (await response.json()) as OpportunityApiResponse; + return responseBody; +}; diff --git a/frontend/src/services/fetch/fetchers/searchFetcher.ts b/frontend/src/services/fetch/fetchers/searchFetcher.ts index 62956dbfe..a1f1a1801 100644 --- a/frontend/src/services/fetch/fetchers/searchFetcher.ts +++ b/frontend/src/services/fetch/fetchers/searchFetcher.ts @@ -9,6 +9,7 @@ import { SearchFilterRequestBody, SearchRequestBody, } from "src/types/search/searchRequestTypes"; +import { SearchAPIResponse } from "src/types/search/searchResponseTypes"; const orderByFieldLookup = { relevancy: "relevancy", @@ -52,16 +53,51 @@ export const searchForOpportunities = async (searchInputs: QueryParamData) => { const response = await fetchOpportunitySearch({ body: requestBody, - queryParamData: searchInputs, }); - response.actionType = searchInputs.actionType; - response.fieldChanged = searchInputs.fieldChanged; + const responseBody = (await response.json()) as SearchAPIResponse; - if (!response.data) { + responseBody.actionType = searchInputs.actionType; + responseBody.fieldChanged = searchInputs.fieldChanged; + + if (!responseBody.data) { throw new Error("No data returned from Opportunity Search API"); } - return response; + return responseBody; +}; + +// this is very similar to `searchForOpportunities`, but +// * hardcodes some pagination params +// * sets format param = 'csv' +// * response body on success is not json, so does not parse it +export const downloadOpportunities = async ( + searchInputs: QueryParamData, +): Promise>> => { + const { query } = searchInputs; + const filters = buildFilters(searchInputs); + const pagination = buildPagination(searchInputs); + + const requestBody: SearchRequestBody = { + pagination: { ...pagination, page_size: 5000, page_offset: 1 }, + }; + + if (Object.keys(filters).length > 0) { + requestBody.filters = filters; + } + + if (query) { + requestBody.query = query; + } + + const response = await fetchOpportunitySearch({ + body: { ...requestBody, format: "csv" }, + }); + + if (!response.body) { + throw new Error("No data returned from Opportunity Search API export"); + } + + return response.body; }; // Translate frontend filter param names to expected backend parameter names, and use one_of syntax diff --git a/frontend/src/types/search/searchRequestTypes.ts b/frontend/src/types/search/searchRequestTypes.ts index bafb77406..eab0172a8 100644 --- a/frontend/src/types/search/searchRequestTypes.ts +++ b/frontend/src/types/search/searchRequestTypes.ts @@ -26,6 +26,7 @@ export type SearchRequestBody = { pagination: PaginationRequestBody; filters?: SearchFilterRequestBody; query?: string; + format?: string; }; export enum SearchFetcherActionType { diff --git a/frontend/src/types/searchRequestURLTypes.ts b/frontend/src/types/searchRequestURLTypes.ts index b8a397079..d99744d7a 100644 --- a/frontend/src/types/searchRequestURLTypes.ts +++ b/frontend/src/types/searchRequestURLTypes.ts @@ -5,6 +5,6 @@ export interface ServerSideRouteParams { } // Query param prop for app router server-side pages -export interface ServerSideSearchParams { +export interface OptionalStringDict { [key: string]: string | undefined; } diff --git a/frontend/src/types/uiTypes.ts b/frontend/src/types/uiTypes.ts index 5ff546b55..895022bc9 100644 --- a/frontend/src/types/uiTypes.ts +++ b/frontend/src/types/uiTypes.ts @@ -1,4 +1,4 @@ -import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; +import { OptionalStringDict } from "src/types/searchRequestURLTypes"; export enum Breakpoints { CARD = "card", @@ -13,7 +13,7 @@ export enum Breakpoints { } export type WithFeatureFlagProps = { - searchParams: Promise; + searchParams: Promise; }; export interface ErrorProps { diff --git a/frontend/src/utils/dateUtil.ts b/frontend/src/utils/dateUtil.ts index 47eea08d1..c0fa7afca 100644 --- a/frontend/src/utils/dateUtil.ts +++ b/frontend/src/utils/dateUtil.ts @@ -1,3 +1,10 @@ +import dayjs from "dayjs"; +import advancedFormat from "dayjs/plugin/advancedFormat"; +import timezone from "dayjs/plugin/timezone"; + +dayjs.extend(timezone); +dayjs.extend(advancedFormat); + // Convert "2024-02-21" to "February 21, 2024" export function formatDate(dateStr: string | null): string { if (!dateStr || dateStr.length !== 10) { @@ -23,3 +30,5 @@ export function formatDate(dateStr: string | null): string { }; return date.toLocaleDateString("en-US", options); } + +export const getConfiguredDayJs = () => dayjs; diff --git a/frontend/src/utils/search/convertSearchParamsToProperTypes.ts b/frontend/src/utils/search/convertSearchParamsToProperTypes.ts index 1ece9b231..ce159c0d9 100644 --- a/frontend/src/utils/search/convertSearchParamsToProperTypes.ts +++ b/frontend/src/utils/search/convertSearchParamsToProperTypes.ts @@ -5,7 +5,7 @@ import { SearchFetcherActionType, SortOptions, } from "src/types/search/searchRequestTypes"; -import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; +import { OptionalStringDict } from "src/types/searchRequestURLTypes"; // Search params (query string) coming from the request URL into the server // can be a string, string[], or undefined. @@ -13,7 +13,7 @@ import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; // The above doesn't seem to still be true, should we update? - DWS export function convertSearchParamsToProperTypes( - params: ServerSideSearchParams, + params: OptionalStringDict, ): QueryParamData { return { ...params, diff --git a/frontend/tests/api/auth/search/export/route.test.ts b/frontend/tests/api/auth/search/export/route.test.ts new file mode 100644 index 000000000..8ced08798 --- /dev/null +++ b/frontend/tests/api/auth/search/export/route.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment node + */ + +import { GET } from "src/app/api/search/export/route"; + +import { NextRequest } from "next/server"; + +const fakeRequestForSearchParams = (searchParams: string) => { + return { + nextUrl: { + searchParams: new URLSearchParams(searchParams), + }, + } as NextRequest; +}; + +const fakeConvertedParams = { + actionType: "initialLoad", + agency: new Set(["EPA"]), + category: new Set(), + eligibility: new Set(), + fundingInstrument: new Set(), + page: 1, + query: "", + sortby: null, + status: new Set(["closed"]), +}; + +const mockDownloadOpportunities = jest.fn((params: unknown): unknown => params); + +jest.mock("src/services/fetch/fetchers/searchFetcher", () => ({ + downloadOpportunities: (params: unknown) => mockDownloadOpportunities(params), +})); + +jest.mock("next/server", () => ({ + NextResponse: jest.fn((params: unknown): unknown => ({ + calledWith: params, + })), +})); + +// note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect +// is to throw an error +describe("search export GET request", () => { + afterEach(() => jest.clearAllMocks()); + it("calls downloadOpportunities with expected arguments", async () => { + await GET(fakeRequestForSearchParams("status=closed&agency=EPA")); + expect(mockDownloadOpportunities).toHaveBeenCalledWith(fakeConvertedParams); + }); + + it("returns a new response created from the returned value of downloadOpportunties", async () => { + const response = await GET( + fakeRequestForSearchParams("status=closed&agency=EPA"), + ); + expect(response).toEqual({ + calledWith: fakeConvertedParams, + }); + }); +}); diff --git a/frontend/tests/api/auth/session/route.test.ts b/frontend/tests/api/auth/session/route.test.ts index 1db694619..32d55a506 100644 --- a/frontend/tests/api/auth/session/route.test.ts +++ b/frontend/tests/api/auth/session/route.test.ts @@ -19,7 +19,7 @@ jest.mock("next/server", () => ({ // note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect // is to throw an error -describe("GET request", () => { +describe("session GET request", () => { afterEach(() => jest.clearAllMocks()); it("returns the current session token when one exists", async () => { getSessionMock.mockImplementation(() => ({ diff --git a/frontend/tests/components/search/ExportSearchResultsButton.test.tsx b/frontend/tests/components/search/ExportSearchResultsButton.test.tsx new file mode 100644 index 000000000..a67be7e41 --- /dev/null +++ b/frontend/tests/components/search/ExportSearchResultsButton.test.tsx @@ -0,0 +1,39 @@ +import { axe } from "jest-axe"; +import { render, screen } from "tests/react-utils"; + +import { ExportSearchResultsButton } from "src/components/search/ExportSearchResultsButton"; + +const mockDownloadSearchResultsCSV = jest.fn(); + +const fakeSearchParams = new URLSearchParams(); + +jest.mock( + "src/services/fetch/fetchers/clientSearchResultsDownloadFetcher", + () => ({ + downloadSearchResultsCSV: (params: unknown): unknown => + Promise.resolve(mockDownloadSearchResultsCSV(params)), + }), +); + +jest.mock("next/navigation", () => ({ + useSearchParams: () => fakeSearchParams, +})); + +describe("ExportSearchResultsButton", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + it("should not have basic accessibility issues", async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("calls downloadSearchResultsCSV with correct args on button click", () => { + render(); + const button = screen.getByRole("button"); + button.click(); + + expect(mockDownloadSearchResultsCSV).toHaveBeenCalledWith(fakeSearchParams); + }); +}); diff --git a/frontend/tests/e2e/search/search-download.spec.ts b/frontend/tests/e2e/search/search-download.spec.ts new file mode 100644 index 000000000..d35671e4b --- /dev/null +++ b/frontend/tests/e2e/search/search-download.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Search results export", () => { + test("should download a csv file when requested", async ({ page }, { + project, + }) => { + // downloads work manually in safari, but can't get the test to work + if (project.name.match(/webkit/)) { + return; + } + const downloadPromise = page.waitForEvent("download"); + await page.goto("/search"); + await page + .locator('div[data-testid="search-download-button-container"] > button') + .click(); + const download = await downloadPromise; + expect(download.url()).toBeTruthy(); + }); +}); diff --git a/frontend/tests/e2e/search/search-loading.spec.ts b/frontend/tests/e2e/search/search-loading.spec.ts index 53c02c829..f4bf2db84 100644 --- a/frontend/tests/e2e/search/search-loading.spec.ts +++ b/frontend/tests/e2e/search/search-loading.spec.ts @@ -1,4 +1,4 @@ -import skip, { expect, test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { chromium } from "playwright-core"; import { fillSearchInputAndSubmit, @@ -7,7 +7,7 @@ import { test.describe("Search page tests", () => { // Loadiing indicator resolves too quickly to reliably test in e2e. - skip("should show and hide loading state", async () => { + test.skip("should show and hide loading state", async () => { const searchTerm = generateRandomString([4, 5]); const searchTerm2 = generateRandomString([8]); diff --git a/frontend/tests/e2e/search/search.spec.ts b/frontend/tests/e2e/search/search.spec.ts index d472b124f..d99e7abad 100644 --- a/frontend/tests/e2e/search/search.spec.ts +++ b/frontend/tests/e2e/search/search.spec.ts @@ -54,10 +54,10 @@ test.describe("Search page tests", () => { "category-agriculture": "agriculture", }; - await selectSortBy(page, "agencyDesc"); - await waitForSearchResultsInitialLoad(page); + await selectSortBy(page, "agencyDesc"); + if (project.name.match(/[Mm]obile/)) { await toggleMobileSearchFilters(page); } @@ -156,12 +156,12 @@ test.describe("Search page tests", () => { page, }: PageProps) => { await page.goto("/search"); + await waitForSearchResultsInitialLoad(page); + await selectSortBy(page, "opportunityTitleDesc"); await clickLastPaginationPage(page); - await waitForSearchResultsInitialLoad(page); - const lastSearchResultTitle = await getLastSearchResultTitle(page); await selectSortBy(page, "opportunityTitleAsc"); diff --git a/frontend/tests/services/fetch/FetcherHelpers.test.ts b/frontend/tests/services/fetch/FetcherHelpers.test.ts index 12d69bd23..36b6f0f01 100644 --- a/frontend/tests/services/fetch/FetcherHelpers.test.ts +++ b/frontend/tests/services/fetch/FetcherHelpers.test.ts @@ -1,35 +1,12 @@ import "server-only"; -import { ApiRequestError, NetworkError, UnauthorizedError } from "src/errors"; +import { UnauthorizedError } from "src/errors"; import { createRequestUrl, - sendRequest, throwError, } from "src/services/fetch/fetcherHelpers"; -import { QueryParamData } from "src/types/search/searchRequestTypes"; import { wrapForExpectedError } from "src/utils/testing/commonTestUtils"; -const searchInputs: QueryParamData = { - status: new Set(["active"]), - fundingInstrument: new Set(["grant"]), - eligibility: new Set(["public"]), - agency: new Set(["NASA"]), - category: new Set(["science"]), - query: "space exploration", - sortby: "relevancy", - page: 1, -}; - -const responseJsonMock = jest - .fn() - .mockResolvedValue({ data: [], errors: [], warnings: [] }); - -const fetchMock = jest.fn().mockResolvedValue({ - json: responseJsonMock, - ok: true, - status: 200, -}); - describe("createRequestUrl", () => { it("creates the correct url without search params", () => { const method = "GET"; @@ -75,105 +52,23 @@ describe("createRequestUrl", () => { }); }); -describe("sendRequest", () => { - let originalFetch: typeof global.fetch; - beforeAll(() => { - originalFetch = global.fetch; - }); - afterAll(() => { - global.fetch = originalFetch; - }); - beforeEach(() => { - global.fetch = fetchMock; - }); - it("returns expected response body and calls fetch with expected arguments on successful request", async () => { - const response = await sendRequest("any-url", { - body: JSON.stringify({ key: "value" }), - headers: { - "Content-Type": "application/json", - "Header-Name": "headerValue", - }, - method: "POST", - }); - expect(fetchMock).toHaveBeenCalledWith("any-url", { - body: JSON.stringify({ key: "value" }), - headers: { - "Content-Type": "application/json", - "Header-Name": "headerValue", - }, - method: "POST", - }); - expect(response).toEqual({ data: [], errors: [], warnings: [] }); - }); - - it("handles `not ok` errors as expected", async () => { - const errorMock = jest.fn().mockResolvedValue({ - json: responseJsonMock, - ok: false, - status: 200, - }); - global.fetch = errorMock; - - const sendErrorRequest = async () => { - await sendRequest( - "any-url", - { - body: JSON.stringify({ key: "value" }), - headers: { - "Content-Type": "application/json", - "Header-Name": "headerValue", - }, - method: "POST", - }, - searchInputs, - ); - }; - - await expect(sendErrorRequest()).rejects.toThrow( - new ApiRequestError("", "APIRequestError", 0, { searchInputs }), - ); - }); - - it("handles network errors as expected", async () => { - const networkError = new Error("o no an error"); - const errorMock = jest.fn(() => { - throw networkError; - }); - global.fetch = errorMock; - - const sendErrorRequest = async () => { - await sendRequest( - "any-url", - { - body: JSON.stringify({ key: "value" }), - headers: { - "Content-Type": "application/json", - "Header-Name": "headerValue", - }, - method: "POST", - }, - searchInputs, - ); - }; - - await expect(sendErrorRequest()).rejects.toThrow( - new NetworkError(networkError, searchInputs), - ); - }); -}); - describe("throwError", () => { it("passes along message from response and details from first error, in error type based on status code", async () => { const expectedError = await wrapForExpectedError(() => { throwError( - { data: {}, message: "response message", status_code: 401 }, - "http://any.url", - undefined, { - field: "fieldName", - type: "a subtype", - message: "a detailed message", + data: {}, + message: "response message", + status_code: 401, + errors: [ + { + field: "fieldName", + type: "a subtype", + message: "a detailed message", + }, + ], }, + "http://any.url", ); }); expect(expectedError).toBeInstanceOf(UnauthorizedError); diff --git a/frontend/tests/services/fetch/Fetchers.test.ts b/frontend/tests/services/fetch/Fetchers.test.ts index e651d6b58..1241f1828 100644 --- a/frontend/tests/services/fetch/Fetchers.test.ts +++ b/frontend/tests/services/fetch/Fetchers.test.ts @@ -9,11 +9,26 @@ const createRequestUrlMock = jest.fn( return "fakeurl"; }, ); -const sendRequestMock = jest.fn((..._args) => Promise.resolve("done")); +const fakeJsonBody = { + data: [], + errors: [], + warnings: [], +}; + +const responseJsonMock = jest.fn().mockResolvedValue(fakeJsonBody); + +const fakeResponse = { + json: responseJsonMock, + ok: true, +}; + +const fetchMock = jest.fn().mockResolvedValue(fakeResponse); + const createRequestBodyMock = jest.fn((obj) => JSON.stringify(obj)); const getDefaultHeadersMock = jest.fn(() => ({ "Content-Type": "application/json", })); +const throwErrorMock = jest.fn(); jest.mock("src/services/fetch/fetcherHelpers", () => ({ createRequestUrl: ( @@ -32,9 +47,9 @@ jest.mock("src/services/fetch/fetcherHelpers", () => ({ subPath, _body, ), - sendRequest: (...args: unknown[]) => sendRequestMock(...args), createRequestBody: (arg: unknown) => createRequestBodyMock(arg), getDefaultHeaders: () => getDefaultHeadersMock(), + throwError: (...args: unknown[]): unknown => throwErrorMock(...args), })); jest.mock("react", () => ({ @@ -50,23 +65,25 @@ describe("requesterForEndpoint", () => { method: "POST", }; + let originalFetch: typeof global.fetch; + beforeAll(() => { + originalFetch = global.fetch; + }); + afterAll(() => { + global.fetch = originalFetch; + }); + beforeEach(() => { + global.fetch = fetchMock; + }); + it("returns a function", () => { expect(typeof requesterForEndpoint(basicEndpoint)).toBe("function"); }); - it("returns a function that calls `createRequestUrl` andf `sendRequest` with the expected arguments", async () => { + it("returns a function that calls `createRequestUrl` and `fetch` with the expected arguments", async () => { const requester = requesterForEndpoint(basicEndpoint); await requester({ subPath: "1", - queryParamData: { - page: 1, - status: new Set(), - fundingInstrument: new Set(), - eligibility: new Set(), - agency: new Set(), - category: new Set(), - sortby: null, - }, body: { key: "value" }, additionalHeaders: { "Header-Name": "headerValue" }, }); @@ -78,25 +95,40 @@ describe("requesterForEndpoint", () => { "1", { key: "value" }, ); - expect(sendRequestMock).toHaveBeenCalledWith( - "fakeurl/1", - { - body: JSON.stringify({ key: "value" }), - headers: { - "Content-Type": "application/json", - "Header-Name": "headerValue", - }, - method: "POST", + expect(fetchMock).toHaveBeenCalledWith("fakeurl/1", { + body: JSON.stringify({ key: "value" }), + headers: { + "Content-Type": "application/json", + "Header-Name": "headerValue", }, - { - page: 1, - status: new Set(), - fundingInstrument: new Set(), - eligibility: new Set(), - agency: new Set(), - category: new Set(), - sortby: null, - }, - ); + method: "POST", + }); + }); + it("returns a function that returns a fetch response", async () => { + const requester = requesterForEndpoint(basicEndpoint); + const response = await requester({ + subPath: "1", + body: { key: "value" }, + additionalHeaders: { "Header-Name": "headerValue" }, + }); + expect(response).toEqual(fakeResponse); + }); + it("extracts errors from json response where applicable", async () => { + fetchMock.mockResolvedValue({ + json: responseJsonMock, + ok: false, + status: 404, + headers: { get: () => "application/json" }, + }); + + const requester = requesterForEndpoint(basicEndpoint); + + await requester({ + subPath: "1", + body: { key: "value" }, + additionalHeaders: { "Header-Name": "headerValue" }, + }); + expect(responseJsonMock).toHaveBeenCalledTimes(1); + expect(throwErrorMock).toHaveBeenCalledWith(fakeJsonBody, "fakeurl/1"); }); }); diff --git a/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts index 6c34fec13..dca102a56 100644 --- a/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts +++ b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts @@ -1,6 +1,7 @@ import { buildFilters, buildPagination, + downloadOpportunities, searchForOpportunities, } from "src/services/fetch/fetchers/searchFetcher"; import { @@ -22,7 +23,12 @@ const searchProps: QueryParamData = { fieldChanged: "baseball", }; -const mockfetchOpportunitySearch = jest.fn().mockResolvedValue({ data: {} }); +const mockfetchOpportunitySearch = jest.fn().mockResolvedValue({ + json: () => ({ + data: {}, + }), + body: { data: {} }, +}); jest.mock("react", () => ({ ...jest.requireActual("react"), @@ -36,7 +42,10 @@ jest.mock("src/services/fetch/fetchers/fetchers", () => ({ })); describe("searchForOpportunities", () => { - it("calls request function with correct parameters", async () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it("calls request function with correct parameters and returns json data from response", async () => { const result = await searchForOpportunities(searchProps); expect(mockfetchOpportunitySearch).toHaveBeenCalledWith({ @@ -57,18 +66,6 @@ describe("searchForOpportunities", () => { }, }, }, - queryParamData: { - actionType: "fun", - agency: new Set(), - category: new Set(), - eligibility: new Set(), - fieldChanged: "baseball", - fundingInstrument: new Set(["grant", "cooperative_agreement"]), - page: 1, - query: "research", - sortby: "opportunityNumberAsc", - status: new Set(["forecasted", "posted"]), - }, }); expect(result).toEqual({ @@ -79,6 +76,40 @@ describe("searchForOpportunities", () => { }); }); +describe("downloadOpportunities", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it("calls request function with correct parameters and returns response", async () => { + const result = await downloadOpportunities(searchProps); + + expect(mockfetchOpportunitySearch).toHaveBeenCalledWith({ + body: { + pagination: { + order_by: "opportunity_number", // This should be the actual value being used in the API method + page_offset: 1, + page_size: 5000, + sort_direction: "ascending", // or "descending" based on your sortby parameter + }, + query: "research", + filters: { + opportunity_status: { + one_of: ["forecasted", "posted"], + }, + funding_instrument: { + one_of: ["grant", "cooperative_agreement"], + }, + }, + format: "csv", + }, + }); + + expect(result).toEqual({ + data: {}, + }); + }); +}); + describe("buildFilters", () => { it("maps all params to the correct filter names", () => { const filters = buildFilters({ diff --git a/frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts b/frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts new file mode 100644 index 000000000..38e763a98 --- /dev/null +++ b/frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + +import { downloadSearchResultsCSV } from "src/services/fetch/fetchers/clientSearchResultsDownloadFetcher"; +import { getConfiguredDayJs } from "src/utils/dateUtil"; + +import { ReadonlyURLSearchParams } from "next/navigation"; + +const fakeBlob = new Blob(); + +const getFakeFile = () => + new File( + [fakeBlob], + `grants-search-${getConfiguredDayJs()(new Date()).format("YYYYMMDDHHmm")}.csv`, + { + type: "data:text/csv", + }, + ); + +const mockBlob = jest.fn(() => fakeBlob); + +const mockFetch = jest.fn(() => + Promise.resolve({ + blob: mockBlob, + ok: true, + }), +); +const mockCreateObjectUrl = jest.fn(() => "an object url"); +const mockLocationAssign = jest.fn(); + +describe("downloadSearchResultsCSV", () => { + let originalFetch: typeof global.fetch; + let originalCreateObjectURL: typeof global.URL.createObjectURL; + let originalLocationAssign: typeof global.location.assign; + + beforeEach(() => { + originalCreateObjectURL = global.URL.createObjectURL; + originalLocationAssign = global.location.assign; + Object.defineProperty(global, "location", { + value: { assign: mockLocationAssign }, + }); + originalFetch = global.fetch; + global.fetch = mockFetch as jest.Mock; + global.URL.createObjectURL = mockCreateObjectUrl; + }); + + afterEach(() => { + global.fetch = originalFetch; + global.location.assign = originalLocationAssign; + global.URL.createObjectURL = originalCreateObjectURL; + jest.clearAllMocks(); + }); + + it("calls fetch with correct url", async () => { + await downloadSearchResultsCSV( + new ReadonlyURLSearchParams("status=fake&agency=alsoFake"), + ); + expect(mockFetch).toHaveBeenCalledWith( + "/api/search/export?status=fake&agency=alsoFake", + ); + }); + + it("blobs the response", async () => { + await downloadSearchResultsCSV( + new ReadonlyURLSearchParams("status=fake&agency=alsoFake"), + ); + expect(mockBlob).toHaveBeenCalledTimes(1); + }); + + it("sets location with blob result", async () => { + await downloadSearchResultsCSV( + new ReadonlyURLSearchParams("status=fake&agency=alsoFake"), + ); + expect(mockCreateObjectUrl).toHaveBeenCalledWith(getFakeFile()); + expect(mockLocationAssign).toHaveBeenCalledWith("an object url"); + }); +}); diff --git a/frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts b/frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts new file mode 100644 index 000000000..c1cb082ff --- /dev/null +++ b/frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts @@ -0,0 +1,28 @@ +import { getOpportunityDetails } from "src/services/fetch/fetchers/opportunityFetcher"; + +const fakeResponseBody = { some: "response body" }; +const mockJson = jest.fn(() => fakeResponseBody); + +const mockfetchOpportunity = jest.fn().mockResolvedValue({ + json: mockJson, +}); + +jest.mock("src/services/fetch/fetchers/fetchers", () => ({ + fetchOpportunity: (params: unknown): unknown => { + return mockfetchOpportunity(params); + }, +})); + +describe("getOpportunityDetails", () => { + afterEach(() => jest.clearAllMocks()); + it("calls fetchOpportunity with the correct arguments", async () => { + await getOpportunityDetails("an id"); + expect(mockfetchOpportunity).toHaveBeenCalledWith({ subPath: "an id" }); + }); + + it("returns json from response", async () => { + const result = await getOpportunityDetails("an id"); + expect(mockJson).toHaveBeenCalledTimes(1); + expect(result).toEqual(fakeResponseBody); + }); +});