From 5d50bc407e6a55cdf941dae7184a1113d7b9c297 Mon Sep 17 00:00:00 2001 From: Ryan Lewis <93001277+rylew1@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:37:07 -0700 Subject: [PATCH] [Issue #1477]: Hook up pagination (#1480) ## Summary Fixes #1477 ## Changes proposed - Query params stay in sync with input - Pagination hooked up to query param and search --- frontend/.env.production | 2 +- frontend/.eslintrc.js | 1 + frontend/package-lock.json | 14 +- frontend/package.json | 3 +- frontend/src/app/api/BaseApi.ts | 37 +++--- frontend/src/app/api/SearchOpportunityAPI.ts | 10 +- frontend/src/app/search/SearchForm.tsx | 58 ++++++-- frontend/src/app/search/actions.ts | 19 ++- frontend/src/app/search/page.tsx | 28 +++- frontend/src/components/search/SearchBar.tsx | 22 +++- .../search/SearchOpportunityStatus.tsx | 124 ++++++++++++------ .../components/search/SearchPagination.tsx | 66 ++++++++-- .../components/search/SearchResultsHeader.tsx | 13 +- .../components/search/SearchResultsList.tsx | 26 +++- .../src/components/search/SearchSortBy.tsx | 28 +++- frontend/src/hooks/useSearchParamUpdater.ts | 35 +++++ .../searchfetcher/APISearchFetcher.ts | 15 ++- .../searchfetcher/MockSearchFetcher.ts | 6 +- .../services/searchfetcher/SearchFetcher.ts | 10 +- frontend/src/types/requestURLTypes.ts | 18 +++ frontend/src/types/searchTypes.ts | 5 +- .../src/utils/convertSearchParamsToStrings.ts | 17 +++ .../tests/api/SearchOpportunityApi.test.ts | 51 +++++-- .../search/SearchOpportunityStatus.test.tsx | 75 +++++++++++ .../tests/hooks/useSearchParamUpdater.test.ts | 69 ++++++++++ 25 files changed, 613 insertions(+), 139 deletions(-) create mode 100644 frontend/src/hooks/useSearchParamUpdater.ts create mode 100644 frontend/src/types/requestURLTypes.ts create mode 100644 frontend/src/utils/convertSearchParamsToStrings.ts create mode 100644 frontend/tests/components/search/SearchOpportunityStatus.test.tsx create mode 100644 frontend/tests/hooks/useSearchParamUpdater.test.ts diff --git a/frontend/.env.production b/frontend/.env.production index c17439cf2..833ca5a26 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -14,4 +14,4 @@ SENDY_API_KEY= SENDY_API_URL= SENDY_LIST_ID= -API_URL=http://api-prod-342430507.us-east-1.elb.amazonaws.com +API_URL=http://api.simpler.grants.gov diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 6e0cacc29..023b61c5f 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -44,6 +44,7 @@ module.exports = { ], plugins: ["@typescript-eslint"], rules: { + camelcase: "off", // Prevent dead code accumulation "@typescript-eslint/no-unused-vars": "error", // The usage of `any` defeats the purpose of typescript. Consider using `unknown` type instead instead. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 081c52b48..060162e09 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,8 @@ "react-dom": "^18.2.0", "react-i18next": "^14.0.0", "server-only": "^0.0.1", - "sharp": "^0.33.0" + "sharp": "^0.33.0", + "use-debounce": "^10.0.0" }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.0.2", @@ -25483,6 +25484,17 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.0.tgz", + "integrity": "sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-intl": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c5fc5ee51..33fbf0fea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,8 @@ "react-dom": "^18.2.0", "react-i18next": "^14.0.0", "server-only": "^0.0.1", - "sharp": "^0.33.0" + "sharp": "^0.33.0", + "use-debounce": "^10.0.0" }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.0.2", diff --git a/frontend/src/app/api/BaseApi.ts b/frontend/src/app/api/BaseApi.ts index bb48471c0..4006fd3ff 100644 --- a/frontend/src/app/api/BaseApi.ts +++ b/frontend/src/app/api/BaseApi.ts @@ -4,6 +4,7 @@ // https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment import "server-only"; +import { SearchAPIResponse } from "../../types/searchTypes"; import { compact } from "lodash"; export type ApiMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; @@ -11,13 +12,16 @@ export interface JSONRequestBody { [key: string]: unknown; } -export interface ApiResponseBody { - message: string; - data: TResponseData; - status_code: number; - errors?: unknown[]; // TODO: define error and warning Issue type - warnings?: unknown[]; -} +// TODO: keep for reference on generic response type + +// export interface ApiResponseBody { +// message: string; +// data: TResponseData; +// status_code: number; + +// errors?: unknown[]; // TODO: define error and warning Issue type +// warnings?: unknown[]; +// } export interface HeadersDict { [header: string]: string; @@ -49,7 +53,7 @@ export default abstract class BaseApi { /** * Send an API request. */ - async request( + async request( method: ApiMethod, basePath: string, namespace: string, @@ -74,7 +78,7 @@ export default abstract class BaseApi { }; headers["Content-Type"] = "application/json"; - const response = await this.sendRequest(url, { + const response = await this.sendRequest(url, { body: method === "GET" || !body ? null : createRequestBody(body), headers, method, @@ -86,15 +90,12 @@ export default abstract class BaseApi { /** * Send a request and handle the response */ - private async sendRequest( - url: string, - fetchOptions: RequestInit, - ) { + private async sendRequest(url: string, fetchOptions: RequestInit) { let response: Response; - let responseBody: ApiResponseBody; + let responseBody: SearchAPIResponse; try { response = await fetch(url, fetchOptions); - responseBody = (await response.json()) as ApiResponseBody; + responseBody = (await response.json()) as SearchAPIResponse; } catch (error) { console.log("Network Error encountered => ", error); throw new Error("Network request failed"); @@ -102,7 +103,8 @@ export default abstract class BaseApi { // throw fetchErrorToNetworkError(error); } - const { data, errors, warnings } = responseBody; + const { data, message, pagination_info, status_code, errors, warnings } = + responseBody; if (!response.ok) { console.log( "Not OK Response => ", @@ -119,6 +121,9 @@ export default abstract class BaseApi { return { data, + message, + pagination_info, + status_code, warnings, }; } diff --git a/frontend/src/app/api/SearchOpportunityAPI.ts b/frontend/src/app/api/SearchOpportunityAPI.ts index 00d658349..382d5a6d3 100644 --- a/frontend/src/app/api/SearchOpportunityAPI.ts +++ b/frontend/src/app/api/SearchOpportunityAPI.ts @@ -1,7 +1,6 @@ import "server-only"; -import BaseApi, { JSONRequestBody } from "./BaseApi"; - +import BaseApi from "./BaseApi"; import { Opportunity } from "../../types/searchTypes"; export type SearchResponseData = Opportunity[]; @@ -21,19 +20,18 @@ export default class SearchOpportunityAPI extends BaseApi { return { ...baseHeaders, ...searchHeaders }; } - async searchOpportunities(queryParams?: JSONRequestBody) { + async searchOpportunities(page = 1) { const requestBody = { pagination: { order_by: "opportunity_id", - page_offset: 1, + page_offset: page, page_size: 25, sort_direction: "ascending", }, - ...queryParams, }; const subPath = "search"; - const response = await this.request( + const response = await this.request( "POST", this.basePath, this.namespace, diff --git a/frontend/src/app/search/SearchForm.tsx b/frontend/src/app/search/SearchForm.tsx index 289c121f9..68fedfc26 100644 --- a/frontend/src/app/search/SearchForm.tsx +++ b/frontend/src/app/search/SearchForm.tsx @@ -1,45 +1,81 @@ "use client"; -import React from "react"; +import React, { useRef } from "react"; + +import { ConvertedSearchParams } from "../../types/requestURLTypes"; +import { SearchAPIResponse } from "../../types/searchTypes"; import SearchBar from "../../components/search/SearchBar"; import SearchFilterAgency from "src/components/search/SearchFilterAgency"; import SearchFilterFundingInstrument from "../../components/search/SearchFilterFundingInstrument"; import SearchOpportunityStatus from "../../components/search/SearchOpportunityStatus"; import SearchPagination from "../../components/search/SearchPagination"; -import { SearchResponseData } from "../api/SearchOpportunityAPI"; import SearchResultsHeader from "../../components/search/SearchResultsHeader"; import SearchResultsList from "../../components/search/SearchResultsList"; import { updateResults } from "./actions"; import { useFormState } from "react-dom"; interface SearchFormProps { - initialSearchResults: SearchResponseData; + initialSearchResults: SearchAPIResponse; + requestURLQueryParams: ConvertedSearchParams; } -export function SearchForm({ initialSearchResults }: SearchFormProps) { +export function SearchForm({ + initialSearchResults, + requestURLQueryParams, +}: SearchFormProps) { const [searchResults, updateSearchResultsAction] = useFormState( updateResults, initialSearchResults, ); + const formRef = useRef(null); // allows us to submit form from child components + + const { status, query, sortby, page } = requestURLQueryParams; + + // TODO: move this to server-side calculation? + const maxPaginationError = + searchResults.pagination_info.page_offset > + searchResults.pagination_info.total_pages; + return ( -
+
- +
- +
- - - - + + + +
diff --git a/frontend/src/app/search/actions.ts b/frontend/src/app/search/actions.ts index b6548f92d..4f62ed909 100644 --- a/frontend/src/app/search/actions.ts +++ b/frontend/src/app/search/actions.ts @@ -1,10 +1,25 @@ // All exports in this file are server actions "use server"; +import { SearchAPIResponse } from "../../types/searchTypes"; +import { SearchFetcherProps } from "../../services/searchfetcher/SearchFetcher"; import { getSearchFetcher } from "../../services/searchfetcher/SearchFetcherUtil"; +// Gets MockSearchFetcher or APISearchFetcher based on environment variable const searchFetcher = getSearchFetcher(); -export async function updateResults() { - return await searchFetcher.fetchOpportunities(); +// Server action called when SearchForm is submitted +export async function updateResults( + prevState: SearchAPIResponse, + formData: FormData, +) { + const pageValue = formData.get("currentPage"); + const page = pageValue ? parseInt(pageValue as string, 10) : 1; + const safePage = !isNaN(page) && page > 0 ? page : 1; + + const searchProps: SearchFetcherProps = { + page: safePage, + }; + + return await searchFetcher.fetchOpportunities(searchProps); } diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx index 23460413c..5381ca036 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -1,26 +1,41 @@ +import { + ServerSideRouteParams, + ServerSideSearchParams, +} from "../../types/requestURLTypes"; + import { FeatureFlagsManager } from "../../services/FeatureFlagManager"; import PageSEO from "src/components/PageSEO"; import React from "react"; import SearchCallToAction from "../../components/search/SearchCallToAction"; import { SearchForm } from "./SearchForm"; +import { convertSearchParamsToProperTypes } from "../../utils/convertSearchParamsToStrings"; import { cookies } from "next/headers"; import { getSearchFetcher } from "../../services/searchfetcher/SearchFetcherUtil"; import { notFound } from "next/navigation"; const searchFetcher = getSearchFetcher(); + // TODO: use for i18n when ready // interface RouteParams { // locale: string; // } -export default async function Search() { - const cookieStore = cookies(); - const ffManager = new FeatureFlagsManager(cookieStore); +interface ServerPageProps { + params: ServerSideRouteParams; + searchParams: ServerSideSearchParams; +} + +export default async function Search({ searchParams }: ServerPageProps) { + const ffManager = new FeatureFlagsManager(cookies()); if (!ffManager.isFeatureEnabled("showSearchV0")) { return notFound(); } - const initialSearchResults = await searchFetcher.fetchOpportunities(); + const convertedSearchParams = convertSearchParamsToProperTypes(searchParams); + const initialSearchResults = await searchFetcher.fetchOpportunities( + convertedSearchParams, + ); + return ( <> {/* TODO: i18n */} @@ -29,7 +44,10 @@ export default async function Search() { description="Try out our experimental search page." /> - + ); } diff --git a/frontend/src/components/search/SearchBar.tsx b/frontend/src/components/search/SearchBar.tsx index 9f3fec385..c9c1a36fa 100644 --- a/frontend/src/components/search/SearchBar.tsx +++ b/frontend/src/components/search/SearchBar.tsx @@ -1,6 +1,19 @@ -import React from "react"; +import React, { useState } from "react"; + +import { useSearchParamUpdater } from "../../hooks/useSearchParamUpdater"; + +interface SearchBarProps { + initialQuery: string; +} + +export default function SearchBar({ initialQuery }: SearchBarProps) { + const [inputValue, setInputValue] = useState(initialQuery); + const { updateQueryParams } = useSearchParamUpdater(); + + const handleSubmit = () => { + updateQueryParams(inputValue, "query"); + }; -export default function SearchBar() { return (
@@ -93,7 +107,7 @@ const SearchResultsList: React.FC = ({ tablet:border-base-lighter " > - Agency: {opportunity.summary.agency_name} + Agency: {opportunity?.summary?.agency_name} Opportunity Number:{" "} - {opportunity.opportunity_number} + {opportunity?.opportunity_number}
diff --git a/frontend/src/components/search/SearchSortBy.tsx b/frontend/src/components/search/SearchSortBy.tsx index 6cd51545b..303a2e2ad 100644 --- a/frontend/src/components/search/SearchSortBy.tsx +++ b/frontend/src/components/search/SearchSortBy.tsx @@ -1,3 +1,6 @@ +import { useSearchParamUpdater } from "../../hooks/useSearchParamUpdater"; +import { useState } from "react"; + type SortOption = { label: string; value: string; @@ -16,15 +19,36 @@ const SORT_OPTIONS: SortOption[] = [ { label: "Close Date (Descending)", value: "closeDateDesc" }, ]; -const SearchSortBy: React.FC = () => { +interface SearchSortByProps { + formRef: React.RefObject; + initialSortBy: string; +} + +const SearchSortBy: React.FC = ({ + formRef, + initialSortBy, +}) => { + const [sortBy, setSortBy] = useState(initialSortBy || SORT_OPTIONS[0].value); + const { updateQueryParams } = useSearchParamUpdater(); + + const handleChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setSortBy(newValue); + const key = "sortby"; + updateQueryParams(newValue, key); + formRef?.current?.requestSubmit(); + }; + return (