From 4b68cd2086846e9c4e0b80c4afcc1ae0f8e26eb5 Mon Sep 17 00:00:00 2001 From: Aaron Couch Date: Tue, 6 Aug 2024 14:03:04 -0400 Subject: [PATCH] Add suspense boundary around search page results (navapbc/simpler-grants-gov#101) 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: * [``](https://github.com/navapbc/simpler-grants-gov/pull/101/files#diff-9dbdda5096b97ad049cccea24c5a046581d26c151a6f94fcc32c05cb33ee9dee) * [``](https://github.com/navapbc/simpler-grants-gov/pull/101/files#diff-14a084f66c050414cc2bbd0875256511630438971022073301bbfe91c4aa8cd1) * [``](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 `` 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: image 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. --- .../src/app/[locale]/search/QueryProvider.tsx | 63 +++++ .../src/app/[locale]/search/SearchForm.tsx | 122 -------- frontend/src/app/[locale]/search/actions.ts | 20 -- frontend/src/app/[locale]/search/error.tsx | 119 ++++---- frontend/src/app/[locale]/search/loading.tsx | 2 +- frontend/src/app/[locale]/search/page.tsx | 170 ++++++++++-- frontend/src/components/GrantsIdentifier.tsx | 2 + .../search/.SearchResultsHeader.tsx.swp | Bin 0 -> 12288 bytes frontend/src/components/search/SearchBar.tsx | 24 +- .../components/search/SearchCallToAction.tsx | 3 - .../SearchFilterAccordion.tsx | 117 ++++---- .../SearchFilterCheckbox.tsx | 20 +- ...cyFilterList.ts => SearchFilterOptions.ts} | 213 +++++++++++++- .../SearchFilterSection.tsx | 90 +++--- .../SearchFilterSection/SectionLinkLabel.tsx | 2 +- .../SearchFilterToggleAll.tsx | 2 - .../components/search/SearchFilterAgency.tsx | 24 -- .../search/SearchFilterCategory.tsx | 123 --------- .../search/SearchFilterEligibility.tsx | 115 -------- .../search/SearchFilterFundingInstrument.tsx | 49 ---- .../search/SearchOpportunityStatus.tsx | 88 ++---- .../components/search/SearchPagination.tsx | 83 +++--- .../search/SearchPaginationFetch.tsx | 33 +++ .../components/search/SearchResultsHeader.tsx | 46 ++-- .../search/SearchResultsHeaderFetch.tsx | 26 ++ .../components/search/SearchResultsList.tsx | 69 ----- .../search/SearchResultsListFetch.tsx | 52 ++++ .../search/SearchResultsListItem.tsx | 3 +- .../src/components/search/SearchSortBy.tsx | 36 ++- frontend/src/hooks/useSearchFilter.ts | 260 ------------------ frontend/src/hooks/useSearchFormState.ts | 102 ------- frontend/src/hooks/useSearchParamUpdater.ts | 33 ++- .../src/services/search/FormDataService.ts | 98 ------- .../search/searchfetcher/APISearchFetcher.ts | 2 +- .../search/searchfetcher/SearchFetcher.ts | 4 +- .../utils/search/generateAgencyNameLookup.ts | 4 +- .../tests/api/OpportunityListingApi.test.ts | 4 +- .../components/search/SearchBar.test.tsx | 28 +- .../SearchFilterAccordion.test.tsx | 90 +++--- .../SearchFilterCheckbox.test.tsx | 33 +-- .../SearchFilterSection.test.tsx | 49 ++-- .../SearchFilterToggleAll.test.tsx | 2 +- .../search/SearchOpportunityStatus.test.tsx | 78 ++---- .../search/SearchPagination.test.tsx | 51 ++-- .../search/SearchResultsHeader.test.tsx | 23 +- .../search/SearchResultsListItem.test.tsx | 4 +- .../components/search/SearchSortBy.test.tsx | 40 +-- .../tests/e2e/search/search-loading.spec.ts | 69 +++++ .../tests/e2e/search/search-navigate.spec.ts | 44 +++ frontend/tests/e2e/search/search.spec.ts | 87 +----- frontend/tests/e2e/search/searchSpecUtil.ts | 4 +- frontend/tests/hooks/useFeatureFlags.test.ts | 4 +- frontend/tests/hooks/useSearchFilter.test.ts | 100 ------- .../tests/hooks/useSearchFormState.test.ts | 87 ------ .../tests/hooks/useSearchParamUpdater.test.ts | 52 ++-- frontend/tests/playwright.config.ts | 2 +- 56 files changed, 1173 insertions(+), 1897 deletions(-) create mode 100644 frontend/src/app/[locale]/search/QueryProvider.tsx delete mode 100644 frontend/src/app/[locale]/search/SearchForm.tsx delete mode 100644 frontend/src/app/[locale]/search/actions.ts create mode 100644 frontend/src/components/search/.SearchResultsHeader.tsx.swp rename frontend/src/components/search/SearchFilterAccordion/{filterJSONLists/agencyFilterList.ts => SearchFilterOptions.ts} (64%) delete mode 100644 frontend/src/components/search/SearchFilterAgency.tsx delete mode 100644 frontend/src/components/search/SearchFilterCategory.tsx delete mode 100644 frontend/src/components/search/SearchFilterEligibility.tsx delete mode 100644 frontend/src/components/search/SearchFilterFundingInstrument.tsx create mode 100644 frontend/src/components/search/SearchPaginationFetch.tsx create mode 100644 frontend/src/components/search/SearchResultsHeaderFetch.tsx delete mode 100644 frontend/src/components/search/SearchResultsList.tsx create mode 100644 frontend/src/components/search/SearchResultsListFetch.tsx delete mode 100644 frontend/src/hooks/useSearchFilter.ts delete mode 100644 frontend/src/hooks/useSearchFormState.ts delete mode 100644 frontend/src/services/search/FormDataService.ts create mode 100644 frontend/tests/e2e/search/search-loading.spec.ts create mode 100644 frontend/tests/e2e/search/search-navigate.spec.ts delete mode 100644 frontend/tests/hooks/useSearchFilter.test.ts delete mode 100644 frontend/tests/hooks/useSearchFormState.test.ts 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 0000000000000000000000000000000000000000..5d1b2117bda8cd0ce76c88240ee7f49d146f8534 GIT binary patch literal 12288 zcmeI2&2G~`5XZMjocOBX0a%Qv5~+?u5D40&K#>q2@mb(fIBe{7vf%8R-F4e2EpX{O zARYoC&YU^&7<>shaN-8Dj@_hD1ZU7#`gc9MvoqtL*N3RnuHV0Uot~@J30$*;e82i8 zesN)YPxU1sDbrRMCf(AXu8T}7Y5eQUw`#Fa#@8ydT-Um)$9HXeS_TZ(m40lxDh)cC zNoxYh)|hWZFHN{^1TloOTt

VGycbsw8(_hBF;@gOz-^!%dc0bA_`Aa#^#nD-{|@ z011o}$ZYJNJwQ%1PUfHbaqk#Ca`@i32i_wAB!C2v01`j~NB{{S0VMFB6R_zFc?Qq8 zr+Cg2#W*uD7A6iz00|%gB!C2v01`j~NB{{S0VIF~kib7kfJKBX>?7m@$o>Dn{Qdvq z5FzhC&p{7Bs~{hA9JC+w>mVUtK_5ZyL9amDpcM2NbOtm7+CD(Y3g`-`3OWkZ+Vur6K?&av(V8Z@uUzxMtM)^Fa@2P zqs^rv9le>RkRg-83T~RgQdz)g*C^ef$WE0`1Wz;5t#~SBp|E}bbluC}O9&0rcEXjm=;|~Qu}Bhc zo_al|J3{(arT%GWkD!gMG^Vb!m9?tp*S&{O?J#%#njmF9UgkFJ@~C)+baQh!UD)m* z4L1#T&C@N|pBaA1wQq0O%58Uo3Im>Ut4as3vjB5`fwq-OIFl~i&IyBQV>N)In^a~= z0z)Pv9t+7M=QyRjoh@#Ccp4{yK<T=I#2k9J5&_PVbFU{ z<4lHDC`qSuq*n&;n-)6P!F@2s1Q0=YdyjgWv(S2rBcE|tJ6^5cRRAZr!Se literal 0 HcmV?d00001 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(); + }} /> -