diff --git a/frontend/src/app/[locale]/search/error.tsx b/frontend/src/app/[locale]/search/error.tsx index 0fe1c8127..754ac84bc 100644 --- a/frontend/src/app/[locale]/search/error.tsx +++ b/frontend/src/app/[locale]/search/error.tsx @@ -1,27 +1,17 @@ "use client"; -// Error components must be Client Components import QueryProvider from "src/app/[locale]/search/QueryProvider"; -import { SEARCH_CRUMBS } from "src/constants/breadcrumbs"; -import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher"; +import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; +import { Breakpoints } from "src/types/uiTypes"; +import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes"; +import { useTranslations } from "next-intl"; import { useEffect } from "react"; -import BetaAlert from "src/components/BetaAlert"; -import Breadcrumbs from "src/components/Breadcrumbs"; -import PageSEO from "src/components/PageSEO"; +import ContentDisplayToggle from "src/components/ContentDisplayToggle"; import SearchErrorAlert from "src/components/search/error/SearchErrorAlert"; import SearchBar from "src/components/search/SearchBar"; -import SearchCallToAction from "src/components/search/SearchCallToAction"; -import SearchFilterAccordion from "src/components/search/SearchFilterAccordion/SearchFilterAccordion"; -import { - agencyOptions, - categoryOptions, - eligibilityOptions, - fundingOptions, -} from "src/components/search/SearchFilterAccordion/SearchFilterOptions"; -import SearchOpportunityStatus from "src/components/search/SearchOpportunityStatus"; -import SearchResultsHeader from "src/components/search/SearchResultsHeader"; +import SearchFilters from "src/components/search/SearchFilters"; interface ErrorProps { // Next's error boundary also includes a reset function as a prop for retries, @@ -31,138 +21,91 @@ interface ErrorProps { export interface ParsedError { message: string; - searchInputs: QueryParamData; + searchInputs: ServerSideSearchParams; status: number; type: string; } +function isValidJSON(str: string) { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; // String is not valid JSON + } +} + +function createBlankParsedError(): ParsedError { + return { + type: "NetworkError", + searchInputs: { + query: "", + status: "", + fundingInstrument: "", + eligibility: "", + agency: "", + category: "", + sortby: undefined, + page: "1", + actionType: "initialLoad", + }, + message: "Invalid error message JSON returned", + status: -1, + }; +} + export default function Error({ error }: ErrorProps) { + const t = useTranslations("Search"); + // The error message is passed as an object that's been stringified. // Parse it here. - let parsedErrorData; - let convertedSearchParams; if (!isValidJSON(error.message)) { // the error likely is just a string with a non-specific Server Component error when running the built app // "An error occurred in the Server Components render. The specific message is omitted in production builds..." - parsedErrorData = getParsedError(); - convertedSearchParams = parsedErrorData.searchInputs; + parsedErrorData = createBlankParsedError(); } else { // Valid error thrown from server component parsedErrorData = JSON.parse(error.message) as ParsedError; - - // The error message search inputs had to be converted to arrays in order to be stringified, - // convert those back to sets as we do in non-error flow. - convertedSearchParams = convertSearchInputArraysToSets( - parsedErrorData.searchInputs, - ); } - const { - agency, - category, - eligibility, - fundingInstrument, - query, - sortby, - status, - } = convertedSearchParams; + const convertedSearchParams = convertSearchParamsToProperTypes( + parsedErrorData.searchInputs, + ); + const { agency, category, eligibility, fundingInstrument, query, status } = + convertedSearchParams; useEffect(() => { console.error(error); }, [error]); return ( - <> - - - - - -
-
- -
-
-
- - - - - +
+
+ +
+
+
+ + -
-
- -
- -
-
+ +
+
+
- - +
+ ); } - -function convertSearchInputArraysToSets( - searchInputs: QueryParamData, -): QueryParamData { - return { - ...searchInputs, - status: new Set(searchInputs.status || []), - fundingInstrument: new Set(searchInputs.fundingInstrument || []), - eligibility: new Set(searchInputs.eligibility || []), - agency: new Set(searchInputs.agency || []), - category: new Set(searchInputs.category || []), - }; -} - -function isValidJSON(str: string) { - try { - JSON.parse(str); - return true; - } catch (e) { - return false; // String is not valid JSON - } -} - -function getParsedError() { - return { - type: "NetworkError", - searchInputs: { - status: new Set(), - fundingInstrument: new Set(), - eligibility: new Set(), - agency: new Set(), - category: new Set(), - sortby: null, - page: 1, - actionType: "initialLoad", - }, - message: "Invalid JSON returned", - status: -1, - } as ParsedError; -} diff --git a/frontend/src/app/[locale]/search/layout.tsx b/frontend/src/app/[locale]/search/layout.tsx new file mode 100644 index 000000000..ceadc396b --- /dev/null +++ b/frontend/src/app/[locale]/search/layout.tsx @@ -0,0 +1,27 @@ +import { SEARCH_CRUMBS } from "src/constants/breadcrumbs"; + +import { useTranslations } from "next-intl"; +import { unstable_setRequestLocale } from "next-intl/server"; + +import BetaAlert from "src/components/BetaAlert"; +import Breadcrumbs from "src/components/Breadcrumbs"; +import PageSEO from "src/components/PageSEO"; +import SearchCallToAction from "src/components/search/SearchCallToAction"; + +export default function SearchLayout({ + children, +}: { + children: React.ReactNode; +}) { + unstable_setRequestLocale("en"); + const t = useTranslations("Search"); + return ( + <> + + + + + {children} + + ); +} diff --git a/frontend/src/app/[locale]/search/page.tsx b/frontend/src/app/[locale]/search/page.tsx index 4e09b2b6c..453a70a82 100644 --- a/frontend/src/app/[locale]/search/page.tsx +++ b/frontend/src/app/[locale]/search/page.tsx @@ -1,27 +1,16 @@ import { Metadata } from "next"; -import Loading from "src/app/[locale]/search/loading"; -import QueryProvider from "src/app/[locale]/search/QueryProvider"; -import { SEARCH_CRUMBS } from "src/constants/breadcrumbs"; import withFeatureFlag from "src/hoc/search/withFeatureFlag"; import { Breakpoints } from "src/types/uiTypes"; import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes"; import { useTranslations } from "next-intl"; import { getTranslations, unstable_setRequestLocale } from "next-intl/server"; -import { Suspense } from "react"; -import BetaAlert from "src/components/BetaAlert"; -import Breadcrumbs from "src/components/Breadcrumbs"; import ContentDisplayToggle from "src/components/ContentDisplayToggle"; -import PageSEO from "src/components/PageSEO"; import SearchBar from "src/components/search/SearchBar"; -import SearchCallToAction from "src/components/search/SearchCallToAction"; import SearchFilters from "src/components/search/SearchFilters"; -import SearchPagination from "src/components/search/SearchPagination"; -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 SearchResults from "src/components/search/SearchResults"; +import QueryProvider from "./QueryProvider"; export async function generateMetadata() { const t = await getTranslations({ locale: "en" }); @@ -48,30 +37,15 @@ function Search({ searchParams }: { searchParams: searchParamsTypes }) { unstable_setRequestLocale("en"); const t = useTranslations("Search"); const convertedSearchParams = convertSearchParamsToProperTypes(searchParams); - const { - agency, - category, - eligibility, - fundingInstrument, - page, - query, - sortby, - status, - } = convertedSearchParams; + const { agency, category, eligibility, fundingInstrument, query, status } = + convertedSearchParams; if (!("page" in searchParams)) { searchParams.page = "1"; } - const key = Object.entries(searchParams).join(","); - const pager1key = Object.entries(searchParams).join("-") + "pager1"; - const pager2key = Object.entries(searchParams).join("-") + "pager2"; return ( <> - - - -
@@ -94,58 +68,11 @@ function Search({ searchParams }: { searchParams: searchParamsTypes }) {
- - } - > - - -
- - } - > - - - } - > - - - - } - > - - -
+
diff --git a/frontend/src/app/api/BaseApi.ts b/frontend/src/app/api/BaseApi.ts index a9f75827e..3ab619432 100644 --- a/frontend/src/app/api/BaseApi.ts +++ b/frontend/src/app/api/BaseApi.ts @@ -117,12 +117,12 @@ export default abstract class BaseApi { // or parsing the response. throw fetchErrorToNetworkError(error, queryParamData); } + if (!response.ok) { + handleNotOkResponse(responseBody, url, queryParamData); + } const { data, message, pagination_info, status_code, warnings } = responseBody; - if (!response.ok) { - handleNotOkResponse(responseBody, message, status_code, queryParamData); - } return { data, @@ -183,7 +183,7 @@ function createRequestBody(payload?: JSONRequestBody): XMLHttpRequestBodyInit { /** * Handle request errors */ -export function fetchErrorToNetworkError( +function fetchErrorToNetworkError( error: unknown, searchInputs?: QueryParamData, ) { @@ -197,29 +197,32 @@ export function fetchErrorToNetworkError( function handleNotOkResponse( response: APIResponse, - message: string, - status_code: number, + url: string, searchInputs?: QueryParamData, ) { const { errors } = response; if (isEmpty(errors)) { // No detailed errors provided, throw generic error based on status code - throwError(message, status_code, searchInputs); + throwError(response, url, searchInputs); } else { if (errors) { const firstError = errors[0] as APIResponseError; - throwError(message, status_code, searchInputs, firstError); + throwError(response, url, searchInputs, firstError); } } } const throwError = ( - message: string, - status_code: number, + response: APIResponse, + url: string, searchInputs?: QueryParamData, firstError?: APIResponseError, ) => { - console.error("Throwing error: ", message, status_code, searchInputs); + const { status_code, message } = response; + console.error( + `API request error at ${url} (${status_code}): ${message}`, + searchInputs, + ); // Include just firstError for now, we can expand this // If we need ValidationErrors to be more expanded diff --git a/frontend/src/components/search/SearchFilters.tsx b/frontend/src/components/search/SearchFilters.tsx index 8f2c3cf34..19baadc52 100644 --- a/frontend/src/components/search/SearchFilters.tsx +++ b/frontend/src/components/search/SearchFilters.tsx @@ -1,5 +1,4 @@ import { useTranslations } from "next-intl"; -import { unstable_setRequestLocale } from "next-intl/server"; import SearchFilterAccordion from "src/components/search/SearchFilterAccordion/SearchFilterAccordion"; import { @@ -23,7 +22,6 @@ export default function SearchFilters({ category: Set; opportunityStatus: Set; }) { - unstable_setRequestLocale("en"); const t = useTranslations("Search"); return ( diff --git a/frontend/src/components/search/SearchResults.tsx b/frontend/src/components/search/SearchResults.tsx new file mode 100644 index 000000000..eba5d2bd8 --- /dev/null +++ b/frontend/src/components/search/SearchResults.tsx @@ -0,0 +1,61 @@ +import Loading from "src/app/[locale]/search/loading"; +import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher"; + +import { Suspense } from "react"; + +import SearchPagination from "src/components/search/SearchPagination"; +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"; + +export default function SearchResults({ + searchParams, + query, + loadingMessage, +}: { + searchParams: QueryParamData; + query?: string | null; + loadingMessage: string; +}) { + const { page, sortby } = searchParams; + + const key = Object.entries(searchParams).join(","); + const pager1key = Object.entries(searchParams).join("-") + "pager1"; + const pager2key = Object.entries(searchParams).join("-") + "pager2"; + return ( + <> + } + > + + +
+ + } + > + + + }> + + + + } + > + + +
+ + ); +} diff --git a/frontend/src/components/search/error/SearchErrorAlert.tsx b/frontend/src/components/search/error/SearchErrorAlert.tsx index e03f3a053..ebaec4e90 100644 --- a/frontend/src/components/search/error/SearchErrorAlert.tsx +++ b/frontend/src/components/search/error/SearchErrorAlert.tsx @@ -1,12 +1,9 @@ +import { Alert } from "@trussworks/react-uswds"; + const SearchErrorAlert = () => ( -
-
-

We're sorry.

-

- There seems to have been an error. Please try your search again. -

-
-
+ + There seems to have been an error. Please try your search again. + ); export default SearchErrorAlert; diff --git a/frontend/tests/components/search/SearchResults.test.tsx b/frontend/tests/components/search/SearchResults.test.tsx new file mode 100644 index 000000000..a0a7d1d85 --- /dev/null +++ b/frontend/tests/components/search/SearchResults.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { identity } from "lodash"; +import { useTranslationsMock } from "tests/utils/intlMocks"; + +import SearchResults from "src/components/search/SearchResults"; + +const mockUpdateQueryParams = jest.fn(); + +jest.mock("src/hooks/useSearchParamUpdater", () => ({ + useSearchParamUpdater: () => ({ + updateQueryParams: mockUpdateQueryParams, + }), +})); + +jest.mock("next-intl/server", () => ({ + getTranslations: () => identity, + unstable_setRequestLocale: identity, +})); + +jest.mock("next-intl", () => ({ + useTranslations: () => useTranslationsMock(), +})); + +/* + nested async server components (< ...Fetcher />) are currently breaking the render. + stated workarounds are not working. to get testing minimally working, overriding + Suspense to force display of fallback UI. + + for more see https://github.com/testing-library/react-testing-library/issues/1209 +*/ +jest.mock("react", () => ({ + ...jest.requireActual("react"), + Suspense: ({ fallback }: { fallback: React.Component }) => fallback, +})); + +describe("SearchResults", () => { + it("Renders without errors", () => { + render( + , + ); + + const component = screen.getByText("resultsHeader.message"); + expect(component).toBeInTheDocument(); + }); +});