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(); + }} /> -