Skip to content

Commit

Permalink
Add suspense boundary around search page results (navapbc#101)
Browse files Browse the repository at this point in the history
Fixes #59

This makes the search page static and adds a suspense boundary for the
data being fetched by the server. The data comes from the API and is
called from 3 components:

* [`<SearchPaginationFetch
/>`](https://github.com/navapbc/simpler-grants-gov/pull/101/files#diff-9dbdda5096b97ad049cccea24c5a046581d26c151a6f94fcc32c05cb33ee9dee)
* [`<SearchResultsHeaderFetch
/>`](https://github.com/navapbc/simpler-grants-gov/pull/101/files#diff-14a084f66c050414cc2bbd0875256511630438971022073301bbfe91c4aa8cd1)
* [`<SearchResultsListFetch
/>`](https://github.com/navapbc/simpler-grants-gov/pull/101/files#diff-aabe6a7d19434a9b26199430bbcde5d31a0790aebc4cd844b922ac2fa1348dce)

This also simplifies the state model by pushing state changes directly
to the browser query params and rerendering the changed items. This
makes things a lot simpler and thus a lot of state management code is
removed and there results list is no longer wrapped in a form and
passing form refs between components. This is the recommended approach
by next:
https://nextjs.org/learn/dashboard-app/adding-search-and-pagination

There are several items that needed to be shared among the client
components: the query, total results count, and total pages. These are
wrapped in a `<QueryProvider />` that updates the state of these items.
This was added so that if someone enters a query in the text box and the
clicks a filter their query is not lost, so that the "N Opportunities"
text doesn't need to be rerendered when paging or sorting, and so that
the pager stays the same length when paging or sorting.

The data is fetched a couple of times in a duplicative fashion, however
this follows [NextJS best
practice](https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#sharing-data-between-components)
since the requests are cached.

The pager has been updated to reload only when there is a change in the
page length. Because of an issue with the way the pager renders, it is
unavailable while data is being updated:

<img width="1229" alt="image"
src="https://github.com/navapbc/simpler-grants-gov/assets/512243/a097b0e2-f646-43b5-bc5a-664db02780a2">

This is because the Truss React component [switches between a link and a
button as it
renders](https://github.com/trussworks/react-uswds/blob/main/src/components/Pagination/Pagination.tsx#L42)
and there isn't an option to supply query arguments, so if a user where
to click it they would lose the query params.

Overall this puts us on nice footing for the upcoming work using NextJS
best practice.
  • Loading branch information
acouch committed Sep 18, 2024
1 parent 24107f6 commit c1bf504
Show file tree
Hide file tree
Showing 56 changed files with 1,173 additions and 1,897 deletions.
63 changes: 63 additions & 0 deletions frontend/src/app/[locale]/search/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";
import { createContext, useCallback, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";

interface QueryContextParams {
queryTerm: string | null | undefined;
updateQueryTerm: (term: string) => void;
totalPages: string | null | undefined;
updateTotalPages: (page: string) => void;
totalResults: string;
updateTotalResults: (total: string) => void;
}

export const QueryContext = createContext({} as QueryContextParams);

export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const searchParams = useSearchParams() || undefined;
const defaultTerm = searchParams?.get("query");
const [queryTerm, setQueryTerm] = useState(defaultTerm);
const [totalPages, setTotalPages] = useState("na");
const [totalResults, setTotalResults] = useState("");

const updateQueryTerm = useCallback((term: string) => {
setQueryTerm(term);
}, []);

const updateTotalResults = useCallback((total: string) => {
setTotalResults(total);
}, []);

const updateTotalPages = useCallback((page: string) => {
setTotalPages(page);
}, []);

const contextValue = useMemo(
() => ({
queryTerm,
updateQueryTerm,
totalPages,
updateTotalPages,
totalResults,
updateTotalResults,
}),
[
queryTerm,
updateQueryTerm,
totalPages,
updateTotalPages,
totalResults,
updateTotalResults,
],
);

return (
<QueryContext.Provider value={contextValue}>
{children}
</QueryContext.Provider>
);
}
122 changes: 0 additions & 122 deletions frontend/src/app/[locale]/search/SearchForm.tsx

This file was deleted.

20 changes: 0 additions & 20 deletions frontend/src/app/[locale]/search/actions.ts

This file was deleted.

119 changes: 70 additions & 49 deletions frontend/src/app/[locale]/search/error.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
"use client"; // Error components must be Client Components

import {
PaginationInfo,
SearchAPIResponse,
} from "src/types/search/searchResponseTypes";

import BetaAlert from "src/components/BetaAlert";
import Breadcrumbs from "src/components/Breadcrumbs";
import PageSEO from "src/components/PageSEO";
import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher";
import QueryProvider from "src/app/[locale]/search/QueryProvider";
import SearchBar from "src/components/search/SearchBar";
import SearchCallToAction from "src/components/search/SearchCallToAction";
import { SearchForm } from "src/app/[locale]/search/SearchForm";
import SearchFilterAccordion from "src/components/search/SearchFilterAccordion/SearchFilterAccordion";
import SearchOpportunityStatus from "src/components/search/SearchOpportunityStatus";
import SearchResultsHeader from "src/components/search/SearchResultsHeader";
import {
agencyOptions,
categoryOptions,
eligibilityOptions,
fundingOptions,
} from "src/components/search/SearchFilterAccordion/SearchFilterOptions";
import { SEARCH_CRUMBS } from "src/constants/breadcrumbs";
import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher";
import { useEffect } from "react";
import SearchErrorAlert from "src/components/search/error/SearchErrorAlert";

interface ErrorProps {
// Next's error boundary also includes a reset function as a prop for retries,
Expand All @@ -29,8 +37,8 @@ export default function Error({ error }: ErrorProps) {
// Parse it here.

let parsedErrorData;
const pagination_info = getErrorPaginationInfo();
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..."
Expand All @@ -46,11 +54,15 @@ export default function Error({ error }: ErrorProps) {
parsedErrorData.searchInputs,
);
}

const initialSearchResults: SearchAPIResponse = getErrorInitialSearchResults(
pagination_info,
parsedErrorData,
);
const {
agency,
category,
eligibility,
fundingInstrument,
query,
sortby,
status,
} = convertedSearchParams;

useEffect(() => {
console.error(error);
Expand All @@ -62,46 +74,55 @@ export default function Error({ error }: ErrorProps) {
title="Search Funding Opportunities"
description="Try out our experimental search page."
/>
<BetaAlert />
<Breadcrumbs breadcrumbList={SEARCH_CRUMBS} />
<SearchCallToAction />
<SearchForm
initialSearchResults={initialSearchResults}
requestURLQueryParams={convertedSearchParams}
/>
<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}
/>
</div>
<div className="tablet:grid-col-8">
<SearchResultsHeader sortby={sortby} />
<div className="usa-prose">
<SearchErrorAlert />
</div>
</div>
</div>
</div>
</QueryProvider>
</>
);
}

/*
* Generate empty response data to render the full page on an error
* which otherwise may not have any data.
*/
function getErrorInitialSearchResults(
pagination_info: PaginationInfo,
parsedError: ParsedError,
) {
return {
errors: parsedError ? [{ ...parsedError }] : [{}],
data: [],
pagination_info,
status_code: parsedError?.status || -1,
message: parsedError?.message || "Unable to parse thrown error",
};
}

// There will be no pagination shown on an error
// so the values here just need to be valid for the page to
// load without error
function getErrorPaginationInfo() {
return {
order_by: "opportunity_id",
page_offset: 0,
page_size: 25,
sort_direction: "ascending",
total_pages: 1,
total_records: 0,
};
}

function convertSearchInputArraysToSets(
searchInputs: QueryParamData,
): QueryParamData {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/[locale]/search/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import Spinner from "../../../components/Spinner";
import Spinner from "src/components/Spinner";

export default function Loading() {
// TODO (Issue #1937): Use translation utility for strings in this file
Expand Down
Loading

0 comments on commit c1bf504

Please sign in to comment.