Skip to content

Commit

Permalink
[OUR415-313] Converts search results header to a layout component wit…
Browse files Browse the repository at this point in the history
…h composable children (#262)
  • Loading branch information
rosschapman authored Nov 8, 2024
1 parent d7159a4 commit 2173098
Show file tree
Hide file tree
Showing 13 changed files with 302 additions and 62 deletions.
2 changes: 1 addition & 1 deletion app/components/search/Pagination/ResultsPagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const {
appImages: { algolia },
} = websiteConfig;

const ResultsPagination = ({ noResults }: { noResults: boolean }) => (
const ResultsPagination = ({ noResults }: { noResults?: boolean }) => (
<div>
<div
className={`${styles.paginationContainer} ${
Expand Down
8 changes: 5 additions & 3 deletions app/components/search/Refinements/BrowseRefinementList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { RefinementListItem } from "instantsearch.js/es/connectors/refinement-list/connectRefinementList";
import React, { useEffect, useState } from "react";
import { useRefinementList, UseRefinementListProps } from "react-instantsearch";
import styles from "./RefinementFilters.module.scss";

interface Props extends UseRefinementListProps {
transform?: UseRefinementListProps["transformItems"];
transform?: (items: RefinementListItem[]) => RefinementListItem[];
attribute: string;
}

Expand All @@ -18,7 +19,6 @@ const BrowseRefinementList = ({ attribute, transform }: Props) => {
const { items, refine } = useRefinementList({
attribute,
sortBy: ["name:asc"],
transformItems: transform,
limit: MAXIMUM_ITEMS,
});

Expand All @@ -43,9 +43,11 @@ const BrowseRefinementList = ({ attribute, transform }: Props) => {
setChecked(checked);
};

const transformedItems = transform === undefined ? items : transform(items);

return (
<ul>
{items.map((item) => (
{transformedItems.map((item) => (
<li key={item.label} data-testid={"browserefinementlist-item"}>
<label className={styles.checkBox}>
{item.label}
Expand Down
2 changes: 1 addition & 1 deletion app/components/search/Refinements/ClearSearchButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const ClearSearchButton = () => {
mobileFullWidth={false}
onClick={handleOnClick}
>
Clear Search
<span data-testid={"clear-search-button"}>Clear Search</span>
</Button>
);
};
Expand Down
82 changes: 47 additions & 35 deletions app/components/search/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useRef } from "react";
import React, { useState, useRef, useCallback } from "react";
import type { Category } from "models/Meta";
import {
eligibilitiesMapping,
Expand All @@ -17,6 +17,7 @@ import {
import useClickOutside from "../../../hooks/MenuHooks";
import MobileMapToggleButtons from "./MobileMapToggleButtons";
import styles from "./Sidebar.module.scss";
import { RefinementListItem } from "instantsearch.js/es/connectors/refinement-list/connectRefinementList";

const Sidebar = ({
isSearchResultsPage,
Expand Down Expand Up @@ -51,33 +52,51 @@ const Sidebar = ({
const orderByLabel = (a: { label: string }, b: { label: string }) =>
a.label.localeCompare(b.label);

const orderByPriorityRanking = (
a: { label: string },
b: { label: string }
) => {
if (!subcategoryNames) {
// noop
return 0;
}
// Our API has the ability to sort subcategories using the "child_priority_rank" on the
// CategoryRelationship table. In cases where we want to sort our sidebar categories
// following this order, we can use this sorting function, which sorts the categories
// that we receive from Algolia using the order that we get from the API.
const priorityA = subcategoryNames.indexOf(a.label);
const priorityB = subcategoryNames.indexOf(b.label);

// If an element in the data returned from Algolia does not exist in the API's ordered array
// (i.e., Algolia is out of sync with our API), move the element to the back of the list.
if (priorityA < 0) return 1;
if (priorityB < 0) return -1;

return priorityA - priorityB;
};
const orderByPriorityRanking = useCallback(
(a: { label: string }, b: { label: string }) => {
if (!subcategoryNames) {
// noop
return 0;
}
// Our API has the ability to sort subcategories using the "child_priority_rank" on the
// CategoryRelationship table. In cases where we want to sort our sidebar categories
// following this order, we can use this sorting function, which sorts the categories
// that we receive from Algolia using the order that we get from the API.
const priorityA = subcategoryNames.indexOf(a.label);
const priorityB = subcategoryNames.indexOf(b.label);

// If an element in the data returned from Algolia does not exist in the API's ordered array
// (i.e., Algolia is out of sync with our API), move the element to the back of the list.
if (priorityA < 0) return 1;
if (priorityB < 0) return -1;

return priorityA - priorityB;
},
[subcategoryNames]
);

const onChangeValue = (evt: React.ChangeEvent<HTMLInputElement>) => {
setAroundRadius(Number(evt.target.value));
};

const refinementItemTransform = useCallback(
(items: RefinementListItem[]) =>
items
.filter(({ label }: { label: string }) =>
subcategoryNames.includes(label)
)
.sort(
sortAlgoliaSubcategoryRefinements
? orderByPriorityRanking
: orderByLabel
),
[
orderByPriorityRanking,
sortAlgoliaSubcategoryRefinements,
subcategoryNames,
]
);

// Currently, the Search Results Page uses generic categories/eligibilities while the
// Service Results Page uses COVID-specific categories. This logic determines which
// of these to use as based on the isSearchResultsPage value
Expand Down Expand Up @@ -111,18 +130,9 @@ const Sidebar = ({

// Algolia returns all categories of the union of returned services.
// We filter out any of these categories that are not children of the selected top level
// category returned from the api (`/api/categories/subcategories?id=${categoryID}`).
transform={(items) =>
items
.filter(({ label }: { label: string }) =>
subcategoryNames.includes(label)
)
.sort(
sortAlgoliaSubcategoryRefinements
? orderByPriorityRanking
: orderByLabel
)
}
// category returned from the api
// (`/api/categories/subcategories?id=${categoryID}`).
transform={refinementItemTransform}
/>
);
}
Expand All @@ -146,6 +156,7 @@ const Sidebar = ({
setIsMapCollapsed={setIsMapCollapsed}
/>
</div>

<div
ref={filterMenuRef}
className={`${styles.filtersContainer} ${
Expand All @@ -165,6 +176,7 @@ const Sidebar = ({
</Button>
</span>
</div>

<h2 className={styles.filterResourcesTitleDesktop}>Filter Resources</h2>
<ClearAllFilters />
<div className={styles.filterGroup}>
Expand Down
13 changes: 13 additions & 0 deletions app/components/ui/NoSearchResultsDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from "react";
import styles from "components/search/SearchResults/SearchResults.module.scss";
import ClearSearchButton from "components/search/Refinements/ClearSearchButton";

export const NoSearchResultsDisplay = ({ query }: { query: string | null }) => (
<div className={`${styles.noResultsMessage}`}>
<div className={styles.noResultsText}>
No results {query && `for ${` "${query}" `}`} found in your area.
<br /> Try a different location, filter, or search term.
</div>
{query && <ClearSearchButton />}
</div>
);
10 changes: 10 additions & 0 deletions app/components/ui/SearchResultsHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React, { ReactNode } from "react";
import styles from "components/search/SearchResults/SearchResults.module.scss";

/**
* Layout component for the header above the search results list that allows for
* flexible composition of child components.
*/
export const SearchResultsHeader = ({ children }: { children: ReactNode }) => (
<div className={styles.searchResultsHeader}>{children}</div>
);
29 changes: 29 additions & 0 deletions app/pages/SearchResultsPage/SearchResultsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { InstantSearch } from "react-instantsearch-core";
import { render, screen, waitFor } from "@testing-library/react";
import { SearchResultsPage } from "pages/SearchResultsPage/SearchResultsPage";
import { createSearchClient } from "../../../test/helpers/createSearchClient";

describe("SearchResultsPage", () => {
test("renders the Clear Search button", async () => {
const searchClient = createSearchClient();

render(
<InstantSearch
searchClient={searchClient}
indexName="fake_test_search_index"
initialUiState={{
fake_test_search_index: {
query: "fake query",
},
}}
>
<SearchResultsPage />
</InstantSearch>
);

await waitFor(() => {
expect(screen.getByTestId("clear-search-button")).toBeInTheDocument();
});
});
});
116 changes: 100 additions & 16 deletions app/pages/SearchResultsPage/SearchResultsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,117 @@
import React, { useState } from "react";
import SearchResults from "components/search/SearchResults/SearchResults";
import React, { useCallback, useState } from "react";
import { SearchMapActions } from "components/search/SearchResults/SearchResults";
import Sidebar from "components/search/Sidebar/Sidebar";
import styles from "./SearchResultsPage.module.scss";
import { DEFAULT_AROUND_PRECISION, useAppContext } from "utils";
import { Configure } from "react-instantsearch-core";
import classNames from "classnames";
import { SearchMap } from "components/search/SearchMap/SearchMap";
import { SearchResult } from "components/search/SearchResults/SearchResult";
import {
TransformedSearchHit,
transformSearchResults,
} from "models/SearchHits";
import { useInstantSearch, usePagination } from "react-instantsearch";
import searchResultsStyles from "components/search/SearchResults/SearchResults.module.scss";
import { Loader } from "components/ui/Loader";
import ResultsPagination from "components/search/Pagination/ResultsPagination";
import { NoSearchResultsDisplay } from "components/ui/NoSearchResultsDisplay";
import { SearchResultsHeader } from "components/ui/SearchResultsHeader";
import ClearSearchButton from "components/search/Refinements/ClearSearchButton";

// NOTE: The .searchResultsPage is added plain so that it can be targeted by print-specific css
export const SearchResultsPage = () => {
const [isMapCollapsed, setIsMapCollapsed] = useState(false);
const { aroundUserLocationRadius, aroundLatLng } = useAppContext();
const { refine: refinePagination } = usePagination();
const {
// Results type is algoliasearchHelper.SearchResults<SearchHit>
results: searchResults,
status,
indexUiState: { query = null },
} = useInstantSearch();

const handleFirstResultFocus = useCallback((node: HTMLDivElement | null) => {
if (node) {
node.focus();
}
}, []);

const searchMapHitData = transformSearchResults(searchResults);

const hasNoResults = searchMapHitData.nbHits === 0 && status === "idle" && (
<Loader />
);

const handleAction = (searchMapAction: SearchMapActions) => {
switch (searchMapAction) {
case SearchMapActions.SearchThisArea:
return refinePagination(0);
}
};

return (
<div className={classNames(styles.container, "searchResultsPage")}>
<Configure
aroundLatLng={aroundLatLng}
aroundRadius={aroundUserLocationRadius}
aroundPrecision={DEFAULT_AROUND_PRECISION}
/>

<div className={styles.flexContainer}>
<Sidebar
isSearchResultsPage
isMapCollapsed={isMapCollapsed}
setIsMapCollapsed={setIsMapCollapsed}
<div className={styles.results}>
<div className={classNames(styles.container, "searchResultsPage")}>
<Configure
aroundLatLng={aroundLatLng}
aroundRadius={aroundUserLocationRadius}
aroundPrecision={DEFAULT_AROUND_PRECISION}
/>

<div className={styles.results}>
<SearchResults mobileMapIsCollapsed={isMapCollapsed} />
<div className={styles.flexContainer}>
<Sidebar
isSearchResultsPage
isMapCollapsed={isMapCollapsed}
setIsMapCollapsed={setIsMapCollapsed}
/>

<div className={styles.results}>
<div className={searchResultsStyles.searchResultsAndMapContainer}>
<div
className={`${searchResultsStyles.searchResultsContainer} ${
isMapCollapsed
? searchResultsStyles.resultsPositionWhenMapCollapsed
: ""
}`}
>
<h2 className="sr-only">Search results</h2>
{hasNoResults ? (
<NoSearchResultsDisplay query={query} />
) : (
<>
<SearchResultsHeader>
<h2>{searchResults.nbHits} results</h2>
<ClearSearchButton />
</SearchResultsHeader>
{searchMapHitData.hits.map(
(hit: TransformedSearchHit, index) => (
<SearchResult
hit={hit}
key={`${hit.id} - ${hit.name}`}
ref={index === 0 ? handleFirstResultFocus : null}
/>
)
)}
<div
className={`${searchResultsStyles.paginationContainer} ${
hasNoResults ? searchResultsStyles.hidePagination : ""
}`}
>
<div className={searchResultsStyles.resultsPagination}>
<ResultsPagination noResults={hasNoResults} />
</div>
</div>
</>
)}
</div>
<SearchMap
hits={searchMapHitData.hits}
mobileMapIsCollapsed={isMapCollapsed}
handleSearchMapAction={handleAction}
/>
</div>
</div>
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit 2173098

Please sign in to comment.