diff --git a/frontend/src/app/[locale]/search/QueryProvider.tsx b/frontend/src/app/[locale]/search/QueryProvider.tsx
new file mode 100644
index 000000000..2bb4616ae
--- /dev/null
+++ b/frontend/src/app/[locale]/search/QueryProvider.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/app/[locale]/search/SearchForm.tsx b/frontend/src/app/[locale]/search/SearchForm.tsx
deleted file mode 100644
index 1f5d60341..000000000
--- a/frontend/src/app/[locale]/search/SearchForm.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client";
-
-import SearchPagination, {
- PaginationPosition,
-} from "../../../components/search/SearchPagination";
-
-import { AgencyNamyLookup } from "src/utils/search/generateAgencyNameLookup";
-import { QueryParamData } from "../../../services/search/searchfetcher/SearchFetcher";
-import { SearchAPIResponse } from "../../../types/search/searchResponseTypes";
-import SearchBar from "../../../components/search/SearchBar";
-import SearchFilterAgency from "src/components/search/SearchFilterAgency";
-import SearchFilterCategory from "../../../components/search/SearchFilterCategory";
-import SearchFilterEligibility from "../../../components/search/SearchFilterEligibility";
-import SearchFilterFundingInstrument from "../../../components/search/SearchFilterFundingInstrument";
-import SearchOpportunityStatus from "../../../components/search/SearchOpportunityStatus";
-import SearchResultsHeader from "../../../components/search/SearchResultsHeader";
-import SearchResultsList from "../../../components/search/SearchResultsList";
-import { useSearchFormState } from "../../../hooks/useSearchFormState";
-
-interface SearchFormProps {
- initialSearchResults: SearchAPIResponse;
- requestURLQueryParams: QueryParamData;
- agencyNameLookup?: AgencyNamyLookup;
-}
-
-export function SearchForm({
- initialSearchResults,
- requestURLQueryParams,
- agencyNameLookup,
-}: SearchFormProps) {
- // Capture top level logic, including useFormState in the useSearchFormState hook
- const {
- searchResults, // result of calling server action
- updateSearchResultsAction, // server action function alias
- formRef, // used in children to submit the form
- statusQueryParams,
- queryQueryParams,
- sortbyQueryParams,
- fundingInstrumentQueryParams,
- eligibilityQueryParams,
- agencyQueryParams,
- categoryQueryParams,
- maxPaginationError,
- fieldChangedRef,
- page,
- handlePageChange,
- topPaginationRef,
- handleSubmit,
- } = useSearchFormState(initialSearchResults, requestURLQueryParams);
-
- return (
-
- );
-}
diff --git a/frontend/src/app/[locale]/search/actions.ts b/frontend/src/app/[locale]/search/actions.ts
deleted file mode 100644
index d8a56149b..000000000
--- a/frontend/src/app/[locale]/search/actions.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// All exports in this file are server actions
-"use server";
-
-import { FormDataService } from "../../../services/search/FormDataService";
-import { SearchAPIResponse } from "../../../types/search/searchResponseTypes";
-import { getSearchFetcher } from "../../../services/search/searchfetcher/SearchFetcherUtil";
-
-// Gets MockSearchFetcher or APISearchFetcher based on environment variable
-const searchFetcher = getSearchFetcher();
-
-// Server action called when SearchForm is submitted
-export async function updateResults(
- prevState: SearchAPIResponse,
- formData: FormData,
-): Promise {
- const formDataService = new FormDataService(formData);
- const searchProps = formDataService.processFormData();
-
- return await searchFetcher.fetchOpportunities(searchProps);
-}
diff --git a/frontend/src/app/[locale]/search/error.tsx b/frontend/src/app/[locale]/search/error.tsx
index ddfc71891..0e165bb0f 100644
--- a/frontend/src/app/[locale]/search/error.tsx
+++ b/frontend/src/app/[locale]/search/error.tsx
@@ -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,
@@ -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..."
@@ -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);
@@ -62,46 +74,55 @@ export default function Error({ error }: ErrorProps) {
title="Search Funding Opportunities"
description="Try out our experimental search page."
/>
+
+
-
+
+
+
>
);
}
-/*
- * 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 {
diff --git a/frontend/src/app/[locale]/search/loading.tsx b/frontend/src/app/[locale]/search/loading.tsx
index 8b4feb238..025baa093 100644
--- a/frontend/src/app/[locale]/search/loading.tsx
+++ b/frontend/src/app/[locale]/search/loading.tsx
@@ -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
diff --git a/frontend/src/app/[locale]/search/page.tsx b/frontend/src/app/[locale]/search/page.tsx
index 7f7f207a2..c91209ae3 100644
--- a/frontend/src/app/[locale]/search/page.tsx
+++ b/frontend/src/app/[locale]/search/page.tsx
@@ -1,20 +1,30 @@
import BetaAlert from "src/components/BetaAlert";
+import Breadcrumbs from "src/components/Breadcrumbs";
+import Loading from "src/app/[locale]/search/loading";
+import PageSEO from "src/components/PageSEO";
+import SearchResultsListFetch from "src/components/search/SearchResultsListFetch";
+import QueryProvider from "src/app/[locale]/search/QueryProvider";
+import SearchBar from "src/components/search/SearchBar";
+import SearchCallToAction from "src/components/search/SearchCallToAction";
+import SearchFilterAccordion from "src/components/search/SearchFilterAccordion/SearchFilterAccordion";
+import SearchOpportunityStatus from "src/components/search/SearchOpportunityStatus";
+import SearchPagination from "src/components/search/SearchPagination";
+import SearchPaginationFetch from "src/components/search/SearchPaginationFetch";
+import SearchResultsHeaderFetch from "src/components/search/SearchResultsHeaderFetch";
+import SearchResultsHeader from "src/components/search/SearchResultsHeader";
+import withFeatureFlag from "src/hoc/search/withFeatureFlag";
+import {
+ agencyOptions,
+ categoryOptions,
+ eligibilityOptions,
+ fundingOptions,
+} from "src/components/search/SearchFilterAccordion/SearchFilterOptions";
+import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes";
+import { getTranslations, unstable_setRequestLocale } from "next-intl/server";
import { Metadata } from "next";
-import React from "react";
-import SearchCallToAction from "../../../components/search/SearchCallToAction";
-import { SearchForm } from "./SearchForm";
-import { ServerSideSearchParams } from "../../../types/searchRequestURLTypes";
-import { convertSearchParamsToProperTypes } from "../../../utils/search/convertSearchParamsToProperTypes";
-import { generateAgencyNameLookup } from "src/utils/search/generateAgencyNameLookup";
-import { getSearchFetcher } from "../../../services/search/searchfetcher/SearchFetcherUtil";
-import { getTranslations } from "next-intl/server";
-import withFeatureFlag from "../../../hoc/search/withFeatureFlag";
-
-const searchFetcher = getSearchFetcher();
-
-interface ServerPageProps {
- searchParams: ServerSideSearchParams;
-}
+import { useTranslations } from "next-intl";
+import { SEARCH_CRUMBS } from "src/constants/breadcrumbs";
+import { Suspense } from "react";
export async function generateMetadata() {
const t = await getTranslations({ locale: "en" });
@@ -25,21 +35,133 @@ export async function generateMetadata() {
return meta;
}
-async function Search({ searchParams }: ServerPageProps) {
+interface searchParamsTypes {
+ agency?: string;
+ category?: string;
+ eligibility?: string;
+ fundingInstrument?: string;
+ page?: string;
+ query?: string;
+ sortby?: string;
+ status?: string;
+ [key: string]: string | undefined;
+}
+
+function Search({ searchParams }: { searchParams: searchParamsTypes }) {
+ unstable_setRequestLocale("en");
+ const t = useTranslations("Process");
const convertedSearchParams = convertSearchParamsToProperTypes(searchParams);
- const initialSearchResults = await searchFetcher.fetchOpportunities(
- convertedSearchParams,
- );
+ const {
+ agency,
+ category,
+ eligibility,
+ fundingInstrument,
+ page,
+ query,
+ sortby,
+ 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 (
<>
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+ }
+ >
+
+
+ }>
+
+
+
+ }
+ >
+
+
+
+
+
+
+
>
);
}
diff --git a/frontend/src/components/GrantsIdentifier.tsx b/frontend/src/components/GrantsIdentifier.tsx
index 8b4975188..ad709c7a3 100644
--- a/frontend/src/components/GrantsIdentifier.tsx
+++ b/frontend/src/components/GrantsIdentifier.tsx
@@ -36,6 +36,8 @@ const GrantsIdentifier = () => {
alt={identifier_strings.logo_alt}
src={logo}
className="usa-identifier__logo-img"
+ width={500}
+ height={168}
/>
);
diff --git a/frontend/src/components/search/.SearchResultsHeader.tsx.swp b/frontend/src/components/search/.SearchResultsHeader.tsx.swp
new file mode 100644
index 000000000..5d1b2117b
Binary files /dev/null and b/frontend/src/components/search/.SearchResultsHeader.tsx.swp differ
diff --git a/frontend/src/components/search/SearchBar.tsx b/frontend/src/components/search/SearchBar.tsx
index 295fdbc90..331c2819a 100644
--- a/frontend/src/components/search/SearchBar.tsx
+++ b/frontend/src/components/search/SearchBar.tsx
@@ -1,21 +1,19 @@
"use client";
-
import { Icon } from "@trussworks/react-uswds";
-import { useSearchParamUpdater } from "../../hooks/useSearchParamUpdater";
-import { useState } from "react";
-import { sendGAEvent } from "@next/third-parties/google";
+import { QueryContext } from "src/app/[locale]/search/QueryProvider";
+import { useContext } from "react";
+import { useSearchParamUpdater } from "src/hooks/useSearchParamUpdater";
interface SearchBarProps {
- initialQueryParams: string;
+ query: string | null | undefined;
}
-export default function SearchBar({ initialQueryParams }: SearchBarProps) {
- const [inputValue, setInputValue] = useState(initialQueryParams);
+export default function SearchBar({ query }: SearchBarProps) {
+ const { queryTerm, updateQueryTerm } = useContext(QueryContext);
const { updateQueryParams } = useSearchParamUpdater();
const handleSubmit = () => {
- updateQueryParams(inputValue, "query");
- sendGAEvent("event", "search", { search_term: inputValue });
+ updateQueryParams("", "query", queryTerm, false);
};
return (
@@ -35,10 +33,12 @@ export default function SearchBar({ initialQueryParams }: SearchBarProps) {
id="query"
type="search"
name="query"
- value={inputValue}
- onChange={(e) => setInputValue(e.target.value)}
+ defaultValue={query || ""}
+ onChange={(e) => updateQueryTerm(e.target?.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSubmit();
+ }}
/>
-