Skip to content

Commit

Permalink
[Issue #1492]: Hook up rest of inputs to live API (#1501)
Browse files Browse the repository at this point in the history
Fixes #1492

- Hook up filters (`status`, `agency`, `fundingInstrument`), query
search term, and `sortby` to live API calls
- Formatting done in `SearchOpportunityAPI` to build the proper request
body
  • Loading branch information
rylew1 authored Mar 19, 2024
1 parent 805583a commit 74c9480
Show file tree
Hide file tree
Showing 30 changed files with 384 additions and 111 deletions.
2 changes: 1 addition & 1 deletion frontend/src/app/api/BaseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +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 { SearchAPIResponse } from "../../types/search/searchResponseTypes";
import { compact } from "lodash";

export type ApiMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
Expand Down
95 changes: 83 additions & 12 deletions frontend/src/app/api/SearchOpportunityAPI.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import "server-only";

import BaseApi from "./BaseApi";
import { Opportunity } from "../../types/searchTypes";
import {
PaginationOrderBy,
PaginationRequestBody,
PaginationSortDirection,
SearchFilterRequestBody,
SearchRequestBody,
} from "../../types/search/searchRequestTypes";

export type SearchResponseData = Opportunity[];
import BaseApi from "./BaseApi";
import { SearchFetcherProps } from "../../services/search/searchfetcher/SearchFetcher";

export default class SearchOpportunityAPI extends BaseApi {
get basePath(): string {
Expand All @@ -20,15 +26,21 @@ export default class SearchOpportunityAPI extends BaseApi {
return { ...baseHeaders, ...searchHeaders };
}

async searchOpportunities(page = 1) {
const requestBody = {
pagination: {
order_by: "opportunity_id",
page_offset: page,
page_size: 25,
sort_direction: "ascending",
},
};
async searchOpportunities(searchInputs: SearchFetcherProps) {
const { query } = searchInputs;
const filters = this.buildFilters(searchInputs);
const pagination = this.buildPagination(searchInputs);

const requestBody: SearchRequestBody = { pagination };

// Only add filters if there are some
if (Object.keys(filters).length > 0) {
requestBody.filters = filters;
}

if (query) {
requestBody.query = query;
}

const subPath = "search";
const response = await this.request(
Expand All @@ -41,4 +53,63 @@ export default class SearchOpportunityAPI extends BaseApi {

return response;
}

// Build to one_of syntax
private buildFilters(
searchInputs: SearchFetcherProps,
): SearchFilterRequestBody {
const { agency, status, fundingInstrument } = searchInputs;
const filters: SearchFilterRequestBody = {};

if (agency && agency.size > 0) {
filters.agency = { one_of: Array.from(agency) };
}

if (status && status.size > 0) {
filters.opportunity_status = { one_of: Array.from(status) };
}

if (fundingInstrument && fundingInstrument.size > 0) {
filters.funding_instrument = { one_of: Array.from(fundingInstrument) };
}

return filters;
}

private buildPagination(
searchInputs: SearchFetcherProps,
): PaginationRequestBody {
const { sortby, page } = searchInputs;

// TODO: 3/18/24 - API only allows id or number right now
// Will need to change these two valid values
const orderByFieldLookup = {
opportunityNumber: "opportunity_number",
opportunityTitle: "opportunity_number",
agency: "opportunity_id",
postedDate: "opportunity_id",
closeDate: "opportunity_id",
};

let order_by: PaginationOrderBy = "opportunity_id";
if (sortby) {
for (const [key, value] of Object.entries(orderByFieldLookup)) {
if (sortby.startsWith(key)) {
order_by = value as PaginationOrderBy;
break; // Stop searching after the first match is found
}
}
}

const sort_direction: PaginationSortDirection = sortby?.endsWith("Desc")
? "descending"
: "ascending";

return {
order_by,
page_offset: page,
page_size: 25,
sort_direction,
};
}
}
8 changes: 4 additions & 4 deletions frontend/src/app/search/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { ConvertedSearchParams } from "../../types/searchRequestURLTypes";
import { SearchAPIResponse } from "../../types/searchTypes";
import { SearchAPIResponse } from "../../types/search/searchResponseTypes";
import SearchBar from "../../components/search/SearchBar";
import { SearchFetcherProps } from "../../services/search/searchfetcher/SearchFetcher";
import SearchFilterAgency from "src/components/search/SearchFilterAgency";
import SearchFilterFundingInstrument from "../../components/search/SearchFilterFundingInstrument";
import SearchOpportunityStatus from "../../components/search/SearchOpportunityStatus";
Expand All @@ -13,7 +13,7 @@ import { useSearchFormState } from "../../hooks/useSearchFormState";

interface SearchFormProps {
initialSearchResults: SearchAPIResponse;
requestURLQueryParams: ConvertedSearchParams;
requestURLQueryParams: SearchFetcherProps;
}

export function SearchForm({
Expand Down Expand Up @@ -62,7 +62,7 @@ export function SearchForm({
searchResultsLength={
searchResults.pagination_info.total_records
}
initialSortBy={sortbyQueryParams}
initialQueryParams={sortbyQueryParams}
/>
<SearchPagination
initialQueryParams={pageQueryParams}
Expand Down
14 changes: 4 additions & 10 deletions frontend/src/app/search/actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// All exports in this file are server actions
"use server";

import { SearchAPIResponse } from "../../types/searchTypes";
import { SearchFetcherProps } from "../../services/search/searchfetcher/SearchFetcher";
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
Expand All @@ -13,14 +13,8 @@ export async function updateResults(
prevState: SearchAPIResponse,
formData: FormData,
): Promise<SearchAPIResponse> {
console.log("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,
};
const formDataService = new FormDataService(formData);
const searchProps = formDataService.processFormData();

return await searchFetcher.fetchOpportunities(searchProps);
}
6 changes: 3 additions & 3 deletions frontend/src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ export default function SearchBar({ initialQueryParams }: SearchBarProps) {

return (
<div className="usa-search usa-search--big" role="search">
<label className="usa-sr-only" htmlFor="search-field">
<label className="usa-sr-only" htmlFor="query">
Search
</label>
<input
className="usa-input maxw-none"
id="search-field"
id="query"
type="search"
name="search-text-input"
name="query"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Accordion } from "@trussworks/react-uswds";
import { QueryParamKey } from "../../../types/searchTypes";
import { QueryParamKey } from "../../../types/search/searchResponseTypes";
import SearchFilterCheckbox from "./SearchFilterCheckbox";
import SearchFilterSection from "./SearchFilterSection/SearchFilterSection";
import SearchFilterToggleAll from "./SearchFilterToggleAll";
Expand All @@ -26,7 +26,7 @@ export interface FilterOption {
interface SearchFilterAccordionProps {
initialFilterOptions: FilterOption[];
title: string; // Title in header of accordion
initialQueryParams: string; // comma-separated string list of query params from the request URL
initialQueryParams: Set<string>;
queryParamKey: QueryParamKey; // Ex - In query params, search?{key}=first,second,third
formRef: React.RefObject<HTMLFormElement>;
}
Expand Down Expand Up @@ -90,6 +90,7 @@ export function SearchFilterAccordion({
mounted={mounted}
updateCheckedOption={toggleOptionChecked}
toggleSelectAll={toggleSelectAll}
accordionTitle={title}
isSectionAllSelected={isSectionAllSelected[option.id]}
isSectionNoneSelected={isSectionNoneSelected[option.id]}
/>
Expand All @@ -100,6 +101,7 @@ export function SearchFilterAccordion({
decrement={decrementTotal}
mounted={mounted}
updateCheckedOption={toggleOptionChecked}
accordionTitle={title}
/>
)}
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface SearchFilterCheckboxProps {
decrement: () => void;
mounted: boolean;
updateCheckedOption: (optionId: string, isChecked: boolean) => void;
accordionTitle: string;
}

const SearchFilterCheckbox: React.FC<SearchFilterCheckboxProps> = ({
Expand All @@ -17,18 +18,22 @@ const SearchFilterCheckbox: React.FC<SearchFilterCheckboxProps> = ({
decrement,
mounted,
updateCheckedOption,
accordionTitle,
}) => {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
checked ? increment() : decrement();
updateCheckedOption(option.id, checked);
};

const getNameAttribute = () =>
accordionTitle === "Agency" ? `agency-${option.id}` : option.id;

return (
<FilterCheckbox
id={option.id}
label={option.label}
name={option.id} // value passed to server action {name: "{option.label}", value: "on" } (if no value provided)
name={getNameAttribute()} // value passed to server action {name: "{option.label}", value: "on" } (if no value provided)
onChange={handleChange}
disabled={!mounted}
checked={option.isChecked === true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface SearchFilterSectionProps {
mounted: boolean;
updateCheckedOption: (optionId: string, isChecked: boolean) => void;
toggleSelectAll: (isSelected: boolean, sectionId: string) => void;
accordionTitle: string;
isSectionAllSelected: boolean;
isSectionNoneSelected: boolean;
}
Expand All @@ -26,6 +27,7 @@ const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
mounted,
updateCheckedOption,
toggleSelectAll,
accordionTitle,
isSectionAllSelected,
isSectionNoneSelected,
}) => {
Expand Down Expand Up @@ -59,6 +61,9 @@ const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
}
}, [option.children]);

const getHiddenName = (name: string) =>
accordionTitle === "Agency" ? `agency-${name}` : name;

return (
<div>
<button
Expand Down Expand Up @@ -90,6 +95,7 @@ const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
decrement={decrement}
mounted={mounted}
updateCheckedOption={updateCheckedOption}
accordionTitle={accordionTitle}
// value={child.id} // TODO: consider passing the actual value to the server action
/>
</li>
Expand All @@ -101,7 +107,13 @@ const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
// So we need hidden inputs.
option.children?.map((child) =>
child.isChecked ? (
<input key={child.id} type="hidden" name={child.value} value="on" />
<input
key={child.id}
type="hidden"
// name={child.value}
name={getHiddenName(child.id)}
value="on"
/>
) : null,
)
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { FilterOption } from "../SearchFilterAccordion";

export const agencyFilterList: FilterOption[] = [
{
// Required to see results from locally seeded db
id: "US-ABC",
label: "ABC demo to see results",
value: "US-ABC",
},
{
id: "ARPAH",
label: "All Advanced Research Projects Agency for Health [ARPAH]",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/search/SearchFilterAgency.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SearchFilterAccordion } from "src/components/search/SearchFilterAccordi
import { agencyFilterList } from "./SearchFilterAccordion/filterJSONLists/agencyFilterList";

export interface SearchFilterAgencyProps {
initialQueryParams: string;
initialQueryParams: Set<string>;
formRef: React.RefObject<HTMLFormElement>;
}

Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/search/SearchFilterFundingInstrument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "src/components/search/SearchFilterAccordion/SearchFilterAccordion";

export interface SearchFilterFundingInstrumentProps {
initialQueryParams: string;
initialQueryParams: Set<string>;
formRef: React.RefObject<HTMLFormElement>;
}

Expand All @@ -16,22 +16,22 @@ export default function SearchFilterFundingInstrument({
}: SearchFilterFundingInstrumentProps) {
const initialFilterOptions: FilterOption[] = [
{
id: "funding-opportunity-cooperative_agreement",
id: "funding-instrument-cooperative_agreement",
label: "Cooperative Agreement",
value: "cooperative_agreement",
},
{
id: "funding-opportunity-grant",
id: "funding-instrument-grant",
label: "Grant",
value: "grant",
},
{
id: "funding-opportunity-procurement_contract",
id: "funding-instrument-procurement_contract",
label: "Procurement Contract ",
value: "procurement_contract",
},
{
id: "funding-opportunity-other",
id: "funding-instrument-other",
label: "Other",
value: "other",
},
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/components/search/SearchOpportunityStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface StatusOption {

interface SearchOpportunityStatusProps {
formRef: React.RefObject<HTMLFormElement>;
initialQueryParams: string;
initialQueryParams: Set<string>;
}

const statusOptions: StatusOption[] = [
Expand All @@ -35,12 +35,8 @@ const SearchOpportunityStatus: React.FC<SearchOpportunityStatusProps> = ({
const [mounted, setMounted] = useState(false);
const { updateQueryParams } = useSearchParamUpdater();

const initialStatusesSet = new Set(
initialQueryParams ? initialQueryParams.split(",") : [],
);

const [selectedStatuses, setSelectedStatuses] =
useState<Set<string>>(initialStatusesSet);
useState<Set<string>>(initialQueryParams);

const debouncedUpdate = useDebouncedCallback(
(selectedStatuses: Set<string>) => {
Expand Down
Loading

0 comments on commit 74c9480

Please sign in to comment.