Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Issue #1458]: Setup query param management - writing to the URL part 1 #1462

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
069d171
initial commit
rylew1 Mar 11, 2024
5fbbf66
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 11, 2024
6d8c024
format
rylew1 Mar 11, 2024
3b14e4c
fix what's being imported
rylew1 Mar 11, 2024
603b366
update to custom hook
rylew1 Mar 12, 2024
41f6a9b
rename file and get sortby working with writing query params
rylew1 Mar 12, 2024
37679a2
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 12, 2024
65454f8
fix mounted bug
rylew1 Mar 12, 2024
9c4878c
remove unnecessary imports
rylew1 Mar 12, 2024
abe700f
Update debounce time and todo for search results list link
rylew1 Mar 12, 2024
7c50e46
spacing
rylew1 Mar 12, 2024
b52175c
changeSearchResultsHeader prop to searchResultsLength
rylew1 Mar 12, 2024
f20a7f2
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 12, 2024
078590c
update to one cleaner hook function that updates from any component
rylew1 Mar 12, 2024
774bcd4
update useSearchParamUpdater.test to reference new fn
rylew1 Mar 12, 2024
0ddaf43
add a test for searchopportunitystatus
rylew1 Mar 12, 2024
e59c116
add additional commented out tests
rylew1 Mar 12, 2024
0fee347
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 13, 2024
bfcde67
fix formRef on SearchOpportunityStatus
rylew1 Mar 13, 2024
91e3613
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 13, 2024
693ddd8
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 15, 2024
07ef4b3
Merge branch 'main' into rylew/1458-query-param-mgmt
rylew1 Mar 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
14 changes: 10 additions & 4 deletions frontend/src/app/search/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React from "react";
import React, { useRef } from "react";

import SearchBar from "../../components/search/SearchBar";
import SearchFundingOpportunity from "../../components/search/SearchFundingOpportunity";
import SearchOpportunityStatus from "../../components/search/SearchOpportunityStatus";
Expand All @@ -21,20 +22,25 @@ export function SearchForm({ initialSearchResults }: SearchFormProps) {
initialSearchResults,
);

const formRef = useRef(null);
Copy link
Contributor Author

@rylew1 rylew1 Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ref here allows us to submit from non button inputs (which are basically most our inputs since we just have one submit/search button in the SearchBar component


return (
<form action={updateSearchResultsAction}>
<form ref={formRef} action={updateSearchResultsAction}>
<div className="grid-container">
<div className="search-bar">
<SearchBar />
</div>
<div className="grid-row grid-gap">
<div className="tablet:grid-col-4">
<SearchOpportunityStatus />
<SearchOpportunityStatus formRef={formRef} />
<SearchFundingOpportunity />
</div>
<div className="tablet:grid-col-8">
<div className="usa-prose">
<SearchResultsHeader searchResults={searchResults} />
<SearchResultsHeader
formRef={formRef}
searchResultsLength={searchResults.length}
/>
<SearchPagination />
<SearchResultsList searchResults={searchResults} />
<SearchPagination />
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,34 @@ 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() {
interface ServerPageProps {
params: {
// route params
slug: string;
};
searchParams: {
// query string params
[key: string]: string | string[] | undefined;
};
}

export default async function Search({ searchParams }: ServerPageProps) {
console.log("searchParams serer side =>", searchParams);

const cookieStore = cookies();
const ffManager = new FeatureFlagsManager(cookieStore);
if (!ffManager.isFeatureEnabled("showSearchV0")) {
return notFound();
}

const initialSearchResults = await searchFetcher.fetchOpportunities();

return (
<>
{/* TODO: i18n */}
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import React from "react";
import React, { useState } from "react";

import { useSearchParamUpdater } from "../../hooks/useSearchParamUpdater";

export default function SearchBar() {
const [inputValue, setInputValue] = useState<string>("");
const { updateQueryParams } = useSearchParamUpdater();

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

return (
<div className="usa-search usa-search--big" role="search">
<label className="usa-sr-only" htmlFor="search-field">
Expand All @@ -11,8 +20,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
119 changes: 79 additions & 40 deletions frontend/src/components/search/SearchOpportunityStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,88 @@
import React, { useEffect, useState } from "react";

import { Checkbox } from "@trussworks/react-uswds";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
import { useSearchParamUpdater } from "../../hooks/useSearchParamUpdater";

interface StatusOption {
id: string;
label: string;
value: string;
}

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

const statusOptions: StatusOption[] = [
{ id: "status-forecasted", label: "Forecasted", value: "forecasted" },
{ id: "status-posted", label: "Posted", value: "posted" },
{ id: "status-closed", label: "Closed", value: "closed" },
{ id: "status-archived", label: "Archived", value: "archived" },
];

// Wait a half-second before updating query params
// and submitting the form
const SEARCH_OPPORTUNITY_STATUS_DEBOUNCE_TIME = 500;

const SearchOpportunityStatus: React.FC<SearchOpportunityStatusProps> = ({
formRef,
}) => {
const [mounted, setMounted] = useState(false);
const { updateQueryParams } = useSearchParamUpdater();

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we get to remove the eslint disable statement in the part 2 pr

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

const debouncedUpdate = useDebouncedCallback(
(selectedStatuses: Set<string>) => {
const key = "status";
updateQueryParams(selectedStatuses, key);
formRef?.current?.requestSubmit();
},
SEARCH_OPPORTUNITY_STATUS_DEBOUNCE_TIME,
);

const handleCheck = (statusValue: string, isChecked: boolean) => {
setSelectedStatuses((prevSelectedStatuses) => {
const updatedStatuses = new Set(prevSelectedStatuses);
isChecked
? updatedStatuses.add(statusValue)
: updatedStatuses.delete(statusValue);

debouncedUpdate(updatedStatuses);
return updatedStatuses;
});
};

useEffect(() => {
setMounted(true);
return () => {
setMounted(false);
};
}, []);

export default function SearchOpportunityStatus() {
return (
<>
<h4 className="margin-bottom-1">Opportunity status</h4>

<div className="grid-row flex-wrap">
<div className="grid-col-6 padding-right-1">
<Checkbox
id="status-forecasted"
name="status-forecasted"
label="Forecasted"
tile={true}
className=""
/>
</div>
<div className="grid-col-6 padding-right-1">
<Checkbox
id="status-posted"
name="status-posted"
label="Posted"
tile={true}
className=""
/>
</div>
<div className="grid-col-6 padding-right-1">
<Checkbox
id="status-closed"
name="status-closed"
label="Closed"
tile={true}
className=""
/>
</div>
<div className="grid-col-6 padding-right-1">
<Checkbox
id="status-archived"
name="status-archived"
label="Archived"
tile={true}
className=""
/>
</div>
{statusOptions.map((option) => (
<div key={option.id} className="grid-col-6 padding-right-1">
<Checkbox
id={option.id}
name={option.id}
label={option.label}
tile={true}
onChange={(e) => handleCheck(option.value, e.target.checked)}
disabled={!mounted} // Required to be disabled until hydrated so query params are updated properly
rylew1 marked this conversation as resolved.
Show resolved Hide resolved
/>
</div>
))}
</div>
</>
);
}
};

export default SearchOpportunityStatus;
11 changes: 6 additions & 5 deletions frontend/src/components/search/SearchResultsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import React from "react";
import { SearchResponseData } from "../../app/api/SearchOpportunityAPI";
import SearchSortyBy from "./SearchSortBy";

interface SearchResultsHeaderProps {
searchResults: SearchResponseData;
searchResultsLength: number;
formRef: React.RefObject<HTMLFormElement>;
}

const SearchResultsHeader: React.FC<SearchResultsHeaderProps> = ({
searchResults,
searchResultsLength,
formRef,
}) => {
return (
<>
<div>
<h2>{searchResults.length} Opportunities</h2>
<h2>{searchResultsLength} Opportunities</h2>
</div>
<SearchSortyBy />
<SearchSortyBy formRef={formRef} />
</>
);
};
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/search/SearchResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const SearchResultsList: React.FC<SearchResultsListProps> = ({
<div className="grid-row flex-column">
<div className="grid-col tablet:order-2">
<h2 className="margin-y-105 line-height-serif-2">
{/* TODO: href here needs to be set to:
dev/staging: https://grants.gov/search-results-detail/<opportunity_id>
local/prod: https://grants.gov/search-results-detail/<opportunity_id>
*/}
Copy link
Contributor Author

@rylew1 rylew1 Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note to set the anchor link to the actual grant/opportunity - it differs for each environment

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, this is the same URL twice. eg. https://grants.gov/search-results-detail/<opportunity_id>

<a href="#" className="usa-link usa-link--external">
{opportunity.opportunity_title}
</a>
Expand Down
20 changes: 18 additions & 2 deletions frontend/src/components/search/SearchSortBy.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useSearchParamUpdater } from "../../hooks/useSearchParamUpdater";

type SortOption = {
label: string;
value: string;
Expand All @@ -16,15 +18,29 @@ const SORT_OPTIONS: SortOption[] = [
{ label: "Close Date (Descending)", value: "closeDateDesc" },
rylew1 marked this conversation as resolved.
Show resolved Hide resolved
];

const SearchSortBy: React.FC = () => {
interface SearchSortByProps {
formRef: React.RefObject<HTMLFormElement>;
}

const SearchSortBy: React.FC<SearchSortByProps> = ({ formRef }) => {
const { updateQueryParams } = useSearchParamUpdater();

const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = event.target.value;
const key = "sortby";
updateQueryParams(newValue, key);
formRef?.current?.requestSubmit();
};

return (
<div id="search-sort-by">
<select
className="usa-select"
name="search-sort-by"
id="search-sort-by-select"
onChange={handleChange}
>
{SORT_OPTIONS.map((option: SortOption) => (
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/hooks/useSearchParamUpdater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { usePathname, useSearchParams } from "next/navigation";

export function useSearchParamUpdater() {
const searchParams = useSearchParams();
const pathname = usePathname() || "";

// Singular string param updates include: search input, dropdown, and page numbers
// Multiple param updates include filters: Opportunity Status, Funding Instrument, Eligibility, Agency, Category
const updateQueryParams = (
queryParamValue: string | Set<string>,
key: string,
) => {
const params = new URLSearchParams(searchParams || {});

const finalQueryParamValue =
queryParamValue instanceof Set
? Array.from(queryParamValue).join(",")
: queryParamValue;

if (finalQueryParamValue) {
params.set(key, finalQueryParamValue);
} else {
params.delete(key);
}

let newPath = `${pathname}?${params.toString()}`;
newPath = newPath.replaceAll("%2C", ",");

window.history.pushState({}, "", newPath);
};

return {
updateQueryParams,
rylew1 marked this conversation as resolved.
Show resolved Hide resolved
};
}
Loading
Loading