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 #1491]: Setup states to hide clear all and select all in filter accordion #1495

Merged
merged 8 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion frontend/src/app/search/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ConvertedSearchParams } from "../../types/requestURLTypes";
import { ConvertedSearchParams } from "../../types/searchRequestURLTypes";
import { SearchAPIResponse } from "../../types/searchTypes";
import SearchBar from "../../components/search/SearchBar";
import SearchFilterAgency from "src/components/search/SearchFilterAgency";
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/search/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"use server";

import { SearchAPIResponse } from "../../types/searchTypes";
import { SearchFetcherProps } from "../../services/searchfetcher/SearchFetcher";
import { getSearchFetcher } from "../../services/searchfetcher/SearchFetcherUtil";
import { SearchFetcherProps } from "../../services/search/searchfetcher/SearchFetcher";
import { getSearchFetcher } from "../../services/search/searchfetcher/SearchFetcherUtil";

// Gets MockSearchFetcher or APISearchFetcher based on environment variable
const searchFetcher = getSearchFetcher();
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
ServerSideRouteParams,
ServerSideSearchParams,
} from "../../types/requestURLTypes";
} from "../../types/searchRequestURLTypes";

import { FeatureFlagsManager } from "../../services/FeatureFlagManager";
import PageSEO from "src/components/PageSEO";
Expand All @@ -10,7 +10,7 @@ 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 { getSearchFetcher } from "../../services/search/searchfetcher/SearchFetcherUtil";
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 also moved the SearchFetcher classes from /services/searchfetcher to services/search/searchfetcher/ so there's a bunch of updates in this PR for that

import { notFound } from "next/navigation";

const searchFetcher = getSearchFetcher();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export function SearchFilterAccordion({
toggleSelectAll,
incrementTotal,
decrementTotal,
isAllSelected,
isNoneSelected,
isSectionAllSelected,
isSectionNoneSelected,
} = useSearchFilter(
initialFilterOptions,
initialQueryParams,
Expand All @@ -70,6 +74,8 @@ export function SearchFilterAccordion({
<SearchFilterToggleAll
onSelectAll={() => toggleSelectAll(true)}
onClearAll={() => toggleSelectAll(false)}
isAllSelected={isAllSelected}
isNoneSelected={isNoneSelected}
/>
<ul className="usa-list usa-list--unstyled">
{options.map((option) => (
Expand All @@ -84,6 +90,8 @@ export function SearchFilterAccordion({
mounted={mounted}
updateCheckedOption={toggleOptionChecked}
toggleSelectAll={toggleSelectAll}
isSectionAllSelected={isSectionAllSelected[option.id]}
isSectionNoneSelected={isSectionNoneSelected[option.id]}
/>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea the logic to setup those values is kind of a lot but once you have it it's very easy to use in the components

) : (
<SearchFilterCheckbox
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SearchFilterSectionProps {
mounted: boolean;
updateCheckedOption: (optionId: string, isChecked: boolean) => void;
toggleSelectAll: (isSelected: boolean, sectionId: string) => void;
isSectionAllSelected: boolean;
isSectionNoneSelected: boolean;
}

const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
Expand All @@ -24,6 +26,8 @@ const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
mounted,
updateCheckedOption,
toggleSelectAll,
isSectionAllSelected,
isSectionNoneSelected,
}) => {
const [childrenVisible, setChildrenVisible] = useState<boolean>(false);

Expand Down Expand Up @@ -74,6 +78,8 @@ const SearchFilterSection: React.FC<SearchFilterSectionProps> = ({
<SearchFilterToggleAll
onSelectAll={handleSelectAll}
onClearAll={handleClearAll}
isAllSelected={isSectionAllSelected}
isNoneSelected={isSectionNoneSelected}
/>
<ul className="usa-list usa-list--unstyled margin-left-4">
{option.children?.map((child) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,48 @@
"use client";

interface SearchFilterToggleAllProps {
isAllSelected: boolean;
isNoneSelected: boolean;
onSelectAll?: () => void;
onClearAll?: () => void;
}

const SearchFilterToggleAll: React.FC<SearchFilterToggleAllProps> = ({
onSelectAll,
onClearAll,
}) => (
<div className="grid-row">
<div className="grid-col-fill">
<button
className="usa-button usa-button--unstyled font-sans-xs"
onClick={(event) => {
// form submission is done in useSearchFilter, so
// prevent the onClick from submitting here.
event.preventDefault();
onSelectAll?.();
}}
>
Select All
</button>
isAllSelected,
isNoneSelected,
}) => {
return (
<div className="grid-row">
<div className="grid-col-fill">
<button
className="usa-button usa-button--unstyled font-sans-xs"
onClick={(event) => {
// form submission is done in useSearchFilter, so
// prevent the onClick from submitting here.
event.preventDefault();
onSelectAll?.();
}}
disabled={isAllSelected}
>
Select All
</button>
</div>
<div className="grid-col-fill text-right">
<button
className="usa-button usa-button--unstyled font-sans-xs"
onClick={(event) => {
event.preventDefault();
onClearAll?.();
}}
disabled={isNoneSelected}
>
Clear All
</button>
</div>
</div>
<div className="grid-col-fill text-right">
<button
className="usa-button usa-button--unstyled font-sans-xs"
onClick={(event) => {
event.preventDefault();
onClearAll?.();
}}
>
Clear All
</button>
</div>
</div>
);
);
};

export default SearchFilterToggleAll;
120 changes: 105 additions & 15 deletions frontend/src/hooks/useSearchFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useSearchParamUpdater } from "./useSearchParamUpdater";

// Encapsulate core search filter accordion logic:
// - Keep track of checked count
// - Increment/decrement functions
// - Provide increment/decrement functions
// - Toggle one or all checkboxes
// - Run debounced function that updates query params and submits form
// (Does not cover opportunity status checkbox logic)
Expand All @@ -19,6 +19,7 @@ function useSearchFilter(
queryParamKey: QueryParamKey, // agency, fundingInstrument, eligibility, or category
formRef: React.RefObject<HTMLFormElement>,
) {
const { updateQueryParams } = useSearchParamUpdater();
const [options, setOptions] = useState<FilterOption[]>(() =>
initializeOptions(initialFilterOptions, initialQueryParams),
);
Expand All @@ -43,6 +44,69 @@ function useSearchFilter(
}));
}

// Recursively count checked options
const countChecked = useCallback((optionsList: FilterOption[]): number => {
return optionsList.reduce((acc, option) => {
return option.children
? acc + countChecked(option.children)
: acc + (option.isChecked ? 1 : 0);
}, 0);
}, []);

// Used for disabled select all / clear all states
const determineInitialSelectionStates = useCallback(
(options: FilterOption[]) => {
const totalOptions = options.reduce((total, option) => {
return total + (option.children ? option.children.length : 1);
}, 0);

const totalChecked = countChecked(options);
const allSelected = totalChecked === totalOptions;
const noneSelected = totalChecked === 0;

type SectionStates = {
isSectionAllSelected: { [key: string]: boolean };
isSectionNoneSelected: { [key: string]: boolean };
};

const sectionStates = options.reduce<SectionStates>(
(acc, option) => {
if (option.children) {
const totalInSection = option.children.length;
const checkedInSection = countChecked(option.children);
acc.isSectionAllSelected[option.id] =
totalInSection === checkedInSection;
acc.isSectionNoneSelected[option.id] = checkedInSection === 0;
}
return acc;
},
{ isSectionAllSelected: {}, isSectionNoneSelected: {} },
);

return {
allSelected,
noneSelected,
...sectionStates,
};
},
[countChecked],
);

const initialSelectionStates = determineInitialSelectionStates(options);

const [isAllSelected, setIsAllSelected] = useState<boolean>(
initialSelectionStates.allSelected,
);
const [isNoneSelected, setIsNoneSelected] = useState<boolean>(
initialSelectionStates.noneSelected,
);
const [isSectionAllSelected, setIsSectionAllSelected] = useState<{
[key: string]: boolean;
}>(initialSelectionStates.isSectionAllSelected);
const [isSectionNoneSelected, setIsSectionNoneSelected] = useState<{
[key: string]: boolean;
}>(initialSelectionStates.isSectionNoneSelected);

Copy link
Contributor Author

@rylew1 rylew1 Mar 18, 2024

Choose a reason for hiding this comment

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

loads of logic to manage the {select,clear} all disabled state for both sections and non-sections

const [checkedTotal, setCheckedTotal] = useState<number>(0);
const incrementTotal = () => {
setCheckedTotal(checkedTotal + 1);
Expand All @@ -51,22 +115,11 @@ function useSearchFilter(
setCheckedTotal(checkedTotal - 1);
};

const { updateQueryParams } = useSearchParamUpdater();

const [mounted, setMounted] = useState<boolean>(false);
useEffect(() => {
setMounted(true);
}, []);

// Recursively count checked options
const countChecked = useCallback((optionsList: FilterOption[]): number => {
return optionsList.reduce((acc, option) => {
return option.children
? acc + countChecked(option.children)
: acc + (option.isChecked ? 1 : 0);
}, 0);
}, []);

// Recursively toggle options
const recursiveToggle = useCallback(
(
Expand Down Expand Up @@ -119,6 +172,30 @@ function useSearchFilter(
formRef.current?.requestSubmit();
}, 500);

// Extracted function to calculate and update selection states
const updateSelectionStates = useCallback(
(newOptions: FilterOption[], sectionId?: string | undefined) => {
const newSelectionStates = determineInitialSelectionStates(newOptions);
setIsAllSelected(newSelectionStates.allSelected);
setIsNoneSelected(newSelectionStates.noneSelected);

if (sectionId) {
setIsSectionAllSelected((prevState) => ({
...prevState,
[sectionId]: newSelectionStates.isSectionAllSelected[sectionId],
}));
setIsSectionNoneSelected((prevState) => ({
...prevState,
[sectionId]: newSelectionStates.isSectionNoneSelected[sectionId],
}));
} else {
setIsSectionAllSelected(newSelectionStates.isSectionAllSelected);
setIsSectionNoneSelected(newSelectionStates.isSectionNoneSelected);
}
},
[determineInitialSelectionStates],
);

// Toggle all checkbox options on the accordion, or all within a section
const toggleSelectAll = useCallback(
(isSelected: boolean, sectionId?: string) => {
Expand All @@ -129,11 +206,13 @@ function useSearchFilter(
sectionId,
);

updateSelectionStates(newOptions, sectionId);

debouncedUpdateQueryParams();
return newOptions;
});
},
[recursiveToggle, debouncedUpdateQueryParams],
[recursiveToggle, debouncedUpdateQueryParams, updateSelectionStates],
);

// Toggle a single option
Expand All @@ -147,13 +226,20 @@ function useSearchFilter(
children: opt.children ? updateChecked(opt.children) : undefined,
}));
};
const newOptions = updateChecked(prevOptions);
updateSelectionStates(newOptions);

// If the option being toggled has children, pass the option's id to update its specific section state
if (prevOptions.find((opt) => opt.id === optionId)?.children) {
updateSelectionStates(newOptions, optionId);
}

// Trigger the debounced update when options/checkboxes change
debouncedUpdateQueryParams();
return updateChecked(prevOptions);
return newOptions;
});
},
[debouncedUpdateQueryParams],
[debouncedUpdateQueryParams, updateSelectionStates],
);

// The total count of checked options
Expand All @@ -168,6 +254,10 @@ function useSearchFilter(
totalCheckedCount,
incrementTotal,
decrementTotal,
isAllSelected,
isNoneSelected,
isSectionAllSelected,
isSectionNoneSelected,
};
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/useSearchFormState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ConvertedSearchParams } from "../types/requestURLTypes";
import { ConvertedSearchParams } from "../types/searchRequestURLTypes";
import { SearchAPIResponse } from "../types/searchTypes";
import { updateResults } from "../app/search/actions";
import { useFormState } from "react-dom";
Expand Down
Loading
Loading