Skip to content

Commit

Permalink
[Issue #1477]: Hook up pagination (#1480)
Browse files Browse the repository at this point in the history
## Summary
Fixes #1477 

## Changes proposed
- Query params stay in sync with input
- Pagination hooked up to query param and search
  • Loading branch information
rylew1 authored Mar 16, 2024
1 parent 7e34298 commit 5d50bc4
Show file tree
Hide file tree
Showing 25 changed files with 613 additions and 139 deletions.
2 changes: 1 addition & 1 deletion frontend/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 21 additions & 16 deletions frontend/src/app/api/BaseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@
// 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";
export interface JSONRequestBody {
[key: string]: unknown;
}

export interface ApiResponseBody<TResponseData> {
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<TResponseData> {
// message: string;
// data: TResponseData;
// status_code: number;

// errors?: unknown[]; // TODO: define error and warning Issue type
// warnings?: unknown[];
// }

export interface HeadersDict {
[header: string]: string;
Expand Down Expand Up @@ -49,7 +53,7 @@ export default abstract class BaseApi {
/**
* Send an API request.
*/
async request<TResponseData>(
async request(
method: ApiMethod,
basePath: string,
namespace: string,
Expand All @@ -74,7 +78,7 @@ export default abstract class BaseApi {
};

headers["Content-Type"] = "application/json";
const response = await this.sendRequest<TResponseData>(url, {
const response = await this.sendRequest(url, {
body: method === "GET" || !body ? null : createRequestBody(body),
headers,
method,
Expand All @@ -86,23 +90,21 @@ export default abstract class BaseApi {
/**
* Send a request and handle the response
*/
private async sendRequest<TResponseData>(
url: string,
fetchOptions: RequestInit,
) {
private async sendRequest(url: string, fetchOptions: RequestInit) {
let response: Response;
let responseBody: ApiResponseBody<TResponseData>;
let responseBody: SearchAPIResponse;
try {
response = await fetch(url, fetchOptions);
responseBody = (await response.json()) as ApiResponseBody<TResponseData>;
responseBody = (await response.json()) as SearchAPIResponse;
} catch (error) {
console.log("Network Error encountered => ", error);
throw new Error("Network request failed");
// TODO: Error management
// 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 => ",
Expand All @@ -119,6 +121,9 @@ export default abstract class BaseApi {

return {
data,
message,
pagination_info,
status_code,
warnings,
};
}
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/app/api/SearchOpportunityAPI.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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<SearchResponseData>(
const response = await this.request(
"POST",
this.basePath,
this.namespace,
Expand Down
58 changes: 47 additions & 11 deletions frontend/src/app/search/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form action={updateSearchResultsAction}>
<form ref={formRef} action={updateSearchResultsAction}>
<div className="grid-container">
<div className="search-bar">
<SearchBar />
<SearchBar initialQuery={query} />
</div>
<div className="grid-row grid-gap">
<div className="tablet:grid-col-4">
<SearchOpportunityStatus />
<SearchOpportunityStatus
formRef={formRef}
initialStatuses={status}
/>
<SearchFilterFundingInstrument />
<SearchFilterAgency />
</div>
<div className="tablet:grid-col-8">
<div className="usa-prose">
<SearchResultsHeader searchResults={searchResults} />
<SearchPagination />
<SearchResultsList searchResults={searchResults} />
<SearchPagination />
<SearchResultsHeader
formRef={formRef}
searchResultsLength={
searchResults.pagination_info.total_records
}
initialSortBy={sortby}
/>
<SearchPagination
page={page}
formRef={formRef}
showHiddenInput={true}
totalPages={searchResults.pagination_info.total_pages}
/>
<SearchResultsList
searchResults={searchResults.data}
maxPaginationError={maxPaginationError}
/>
<SearchPagination
page={page}
formRef={formRef}
totalPages={searchResults.pagination_info.total_pages}
/>
</div>
</div>
</div>
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/app/search/actions.ts
Original file line number Diff line number Diff line change
@@ -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);
}
28 changes: 23 additions & 5 deletions frontend/src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -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 */}
Expand All @@ -29,7 +44,10 @@ export default async function Search() {
description="Try out our experimental search page."
/>
<SearchCallToAction />
<SearchForm initialSearchResults={initialSearchResults} />
<SearchForm
initialSearchResults={initialSearchResults}
requestURLQueryParams={convertedSearchParams}
/>
</>
);
}
22 changes: 19 additions & 3 deletions frontend/src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(initialQuery);
const { updateQueryParams } = useSearchParamUpdater();

const handleSubmit = () => {
updateQueryParams(inputValue, "query");
};

export default function SearchBar() {
return (
<div className="usa-search usa-search--big" role="search">
<label className="usa-sr-only" htmlFor="search-field">
Expand All @@ -11,8 +24,11 @@ export default function SearchBar() {
id="search-field"
type="search"
name="search-text-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button className="usa-button" type="submit">

<button className="usa-button" type="submit" onClick={handleSubmit}>
<span className="usa-search__submit-text">Search </span>
{/* <img
src="/assets/img/usa-icons-bg/search--white.svg"
Expand Down
Loading

0 comments on commit 5d50bc4

Please sign in to comment.