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

[Task 2260] refactor search error page for maintainability #2434

Merged
merged 5 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 67 additions & 124 deletions frontend/src/app/[locale]/search/error.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -31,138 +21,91 @@ interface ErrorProps {

export interface ParsedError {
message: string;
searchInputs: QueryParamData;
searchInputs: ServerSideSearchParams;
status: number;
type: string;
}

function isValidJSON(str: string) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this should probably move to a util, but didn't want to cause unnecessary churn creating a new file for it today

try {
JSON.parse(str);
return true;
} catch (e) {
return false; // String is not valid JSON
}
}

function createBlankParsedError(): ParsedError {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

it seems like there should be a better way but I think I'd need more context into our error throwing / handling patterns to take an informed approach. For now just tried to change as little as possible to keep this working

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 (
<>
<PageSEO
title="Search Funding Opportunities"
description="Try out our experimental search page."
/>
<BetaAlert />
<Breadcrumbs breadcrumbList={SEARCH_CRUMBS} />
<SearchCallToAction />
<QueryProvider>
<div className="grid-container">
<div className="search-bar">
<SearchBar query={query} />
</div>
<div className="grid-row grid-gap">
<div className="tablet:grid-col-4">
<SearchOpportunityStatus query={status} />
<SearchFilterAccordion
filterOptions={fundingOptions}
title="Funding instrument"
queryParamKey="fundingInstrument"
query={fundingInstrument}
/>
<SearchFilterAccordion
filterOptions={eligibilityOptions}
title="Eligibility"
queryParamKey="eligibility"
query={eligibility}
/>
<SearchFilterAccordion
filterOptions={agencyOptions}
title="Agency"
queryParamKey="agency"
query={agency}
/>
<SearchFilterAccordion
filterOptions={categoryOptions}
title="Category"
queryParamKey="category"
query={category}
<QueryProvider>
<div className="grid-container">
<div className="search-bar">
<SearchBar query={query} />
</div>
<div className="grid-row grid-gap">
<div className="tablet:grid-col-4">
<ContentDisplayToggle
showCallToAction={t("filterDisplayToggle.showFilters")}
hideCallToAction={t("filterDisplayToggle.hideFilters")}
breakpoint={Breakpoints.TABLET}
>
<SearchFilters
opportunityStatus={status}
eligibility={eligibility}
category={category}
fundingInstrument={fundingInstrument}
agency={agency}
/>
</div>
<div className="tablet:grid-col-8">
<SearchResultsHeader sortby={sortby} />
<div className="usa-prose">
<SearchErrorAlert />
</div>
</div>
</ContentDisplayToggle>
</div>
<div className="tablet:grid-col-8">
<SearchErrorAlert />
</div>
</div>
</QueryProvider>
</>
</div>
</QueryProvider>
);
}

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;
}
27 changes: 27 additions & 0 deletions frontend/src/app/[locale]/search/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<PageSEO title={t("title")} description={t("meta_description")} />
<BetaAlert />
<Breadcrumbs breadcrumbList={SEARCH_CRUMBS} />
<SearchCallToAction />
{children}
</>
);
}
91 changes: 9 additions & 82 deletions frontend/src/app/[locale]/search/page.tsx
Original file line number Diff line number Diff line change
@@ -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" });
Expand All @@ -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 (
<>
<PageSEO title={t("title")} description={t("meta_description")} />
<BetaAlert />
<Breadcrumbs breadcrumbList={SEARCH_CRUMBS} />
<SearchCallToAction />
<QueryProvider>
<div className="grid-container">
<div className="search-bar">
Expand All @@ -94,58 +68,11 @@ function Search({ searchParams }: { searchParams: searchParamsTypes }) {
</ContentDisplayToggle>
</div>
<div className="tablet:grid-col-8">
<Suspense
key={key}
fallback={
<SearchResultsHeader sortby={sortby} loading={false} />
}
>
<SearchResultsHeaderFetch
sortby={sortby}
queryTerm={query}
searchParams={convertedSearchParams}
/>
</Suspense>
<div className="usa-prose">
<Suspense
key={pager1key}
fallback={
<SearchPagination
loading={true}
page={page}
query={query}
/>
}
>
<SearchPaginationFetch
searchParams={convertedSearchParams}
scroll={false}
/>
</Suspense>
<Suspense
key={key}
fallback={<Loading message={t("loading")} />}
>
<SearchResultsListFetch
searchParams={convertedSearchParams}
/>
</Suspense>
<Suspense
key={pager2key}
fallback={
<SearchPagination
loading={true}
page={page}
query={query}
/>
}
>
<SearchPaginationFetch
searchParams={convertedSearchParams}
scroll={true}
/>
</Suspense>
</div>
<SearchResults
searchParams={convertedSearchParams}
query={query}
loadingMessage={t("loading")}
></SearchResults>
</div>
</div>
</div>
Expand Down
Loading
Loading