From 2173098b208e981a310a08565306bde921bf0acd Mon Sep 17 00:00:00 2001 From: Ross Chapman Date: Fri, 8 Nov 2024 11:43:54 -0800 Subject: [PATCH] [OUR415-313] Converts search results header to a layout component with composable children (#262) --- .../search/Pagination/ResultsPagination.tsx | 2 +- .../Refinements/BrowseRefinementList.tsx | 8 +- .../search/Refinements/ClearSearchButton.tsx | 2 +- app/components/search/Sidebar/Sidebar.tsx | 82 +++++++------ app/components/ui/NoSearchResultsDisplay.tsx | 13 ++ app/components/ui/SearchResultsHeader.tsx | 10 ++ .../SearchResultsPage.test.tsx | 29 +++++ .../SearchResultsPage/SearchResultsPage.tsx | 116 +++++++++++++++--- .../ServiceDiscoveryResults.tsx | 78 +++++++++++- jest.config.ts | 7 +- test/helpers/createSearchClient.ts | 2 +- test/jest/__mocks__/fileMock.ts | 4 + test/jest/__mocks__/react-markdown.tsx | 11 ++ 13 files changed, 302 insertions(+), 62 deletions(-) create mode 100644 app/components/ui/NoSearchResultsDisplay.tsx create mode 100644 app/components/ui/SearchResultsHeader.tsx create mode 100644 app/pages/SearchResultsPage/SearchResultsPage.test.tsx create mode 100644 test/jest/__mocks__/fileMock.ts create mode 100644 test/jest/__mocks__/react-markdown.tsx diff --git a/app/components/search/Pagination/ResultsPagination.tsx b/app/components/search/Pagination/ResultsPagination.tsx index c184c0ef2..72fa8ebc3 100644 --- a/app/components/search/Pagination/ResultsPagination.tsx +++ b/app/components/search/Pagination/ResultsPagination.tsx @@ -8,7 +8,7 @@ const { appImages: { algolia }, } = websiteConfig; -const ResultsPagination = ({ noResults }: { noResults: boolean }) => ( +const ResultsPagination = ({ noResults }: { noResults?: boolean }) => (
RefinementListItem[]; attribute: string; } @@ -18,7 +19,6 @@ const BrowseRefinementList = ({ attribute, transform }: Props) => { const { items, refine } = useRefinementList({ attribute, sortBy: ["name:asc"], - transformItems: transform, limit: MAXIMUM_ITEMS, }); @@ -43,9 +43,11 @@ const BrowseRefinementList = ({ attribute, transform }: Props) => { setChecked(checked); }; + const transformedItems = transform === undefined ? items : transform(items); + return (
    - {items.map((item) => ( + {transformedItems.map((item) => (
+
+

Filter Resources

diff --git a/app/components/ui/NoSearchResultsDisplay.tsx b/app/components/ui/NoSearchResultsDisplay.tsx new file mode 100644 index 000000000..cffda47b7 --- /dev/null +++ b/app/components/ui/NoSearchResultsDisplay.tsx @@ -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 }) => ( +
+
+ No results {query && `for ${` "${query}" `}`} found in your area. +
Try a different location, filter, or search term. +
+ {query && } +
+); diff --git a/app/components/ui/SearchResultsHeader.tsx b/app/components/ui/SearchResultsHeader.tsx new file mode 100644 index 000000000..f7b902243 --- /dev/null +++ b/app/components/ui/SearchResultsHeader.tsx @@ -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 }) => ( +
{children}
+); diff --git a/app/pages/SearchResultsPage/SearchResultsPage.test.tsx b/app/pages/SearchResultsPage/SearchResultsPage.test.tsx new file mode 100644 index 000000000..b0b4563f4 --- /dev/null +++ b/app/pages/SearchResultsPage/SearchResultsPage.test.tsx @@ -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( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("clear-search-button")).toBeInTheDocument(); + }); + }); +}); diff --git a/app/pages/SearchResultsPage/SearchResultsPage.tsx b/app/pages/SearchResultsPage/SearchResultsPage.tsx index 8e767e2e9..d96b63375 100644 --- a/app/pages/SearchResultsPage/SearchResultsPage.tsx +++ b/app/pages/SearchResultsPage/SearchResultsPage.tsx @@ -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 + 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" && ( + + ); + + const handleAction = (searchMapAction: SearchMapActions) => { + switch (searchMapAction) { + case SearchMapActions.SearchThisArea: + return refinePagination(0); + } + }; return ( -
- - -
- +
+ -
- +
+ + +
+
+
+

Search results

+ {hasNoResults ? ( + + ) : ( + <> + +

{searchResults.nbHits} results

+ +
+ {searchMapHitData.hits.map( + (hit: TransformedSearchHit, index) => ( + + ) + )} +
+
+ +
+
+ + )} +
+ +
+
diff --git a/app/pages/ServiceDiscoveryResults/ServiceDiscoveryResults.tsx b/app/pages/ServiceDiscoveryResults/ServiceDiscoveryResults.tsx index 88fc51d65..c4232385f 100644 --- a/app/pages/ServiceDiscoveryResults/ServiceDiscoveryResults.tsx +++ b/app/pages/ServiceDiscoveryResults/ServiceDiscoveryResults.tsx @@ -1,14 +1,13 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { useParams } from "react-router-dom"; import * as dataService from "utils/DataService"; import { DEFAULT_AROUND_PRECISION, useAppContext } from "utils"; +import { SearchMapActions } from "components/search/SearchResults/SearchResults"; import { Loader } from "components/ui/Loader"; -import SearchResults from "components/search/SearchResults/SearchResults"; import Sidebar from "components/search/Sidebar/Sidebar"; import { Header } from "components/search/Header/Header"; import { SecondaryNavigationWrapper } from "components/navigation/SecondaryNavigationWrapper"; import { BrowseHeaderSection } from "components/search/Header/BrowseHeaderSection"; - import { useEligibilitiesForCategory, useSubcategoriesForCategory, @@ -16,6 +15,16 @@ import { import { CATEGORIES, ServiceCategory } from "../constants"; import styles from "./ServiceDiscoveryResults.module.scss"; import { Configure } from "react-instantsearch-core"; +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 ResultsPagination from "components/search/Pagination/ResultsPagination"; +import searchResultsStyles from "components/search/SearchResults/SearchResults.module.scss"; +import { SearchResultsHeader } from "components/ui/SearchResultsHeader"; /** Wrapper component that handles state management, URL parsing, and external API requests. */ export const ServiceDiscoveryResults = () => { @@ -32,6 +41,18 @@ export const ServiceDiscoveryResults = () => { const [isMapCollapsed, setIsMapCollapsed] = useState(false); const { userLocation } = useAppContext(); const { aroundUserLocationRadius, aroundLatLng } = useAppContext(); + const { + // Results type is algoliasearchHelper.SearchResults + results: searchResults, + status, + } = useInstantSearch(); + const { refine: refinePagination } = usePagination(); + + const handleFirstResultFocus = useCallback((node: HTMLDivElement | null) => { + if (node) { + node.focus(); + } + }, []); const subcategoryNames = subcategories?.map((c) => c.name); const { name: categoryName, sortAlgoliaSubcategoryRefinements } = category; @@ -50,6 +71,17 @@ export const ServiceDiscoveryResults = () => { ? escapeApostrophes(parentCategory.name) : null; + const searchMapHitData = transformSearchResults(searchResults); + + const hasNoResults = searchMapHitData.nbHits === 0 && status === "idle"; + + const handleAction = (searchMapAction: SearchMapActions) => { + switch (searchMapAction) { + case SearchMapActions.SearchThisArea: + return refinePagination(0); + } + }; + // TS compiler requires explict null type checks if ( eligibilities === null || @@ -88,7 +120,45 @@ export const ServiceDiscoveryResults = () => { />
- +
+
+

Search results

+ <> + +

{searchResults.nbHits} results

+
+ {searchMapHitData.hits.map( + (hit: TransformedSearchHit, index) => ( + + ) + )} +
+
+ +
+
+ +
+ +
diff --git a/jest.config.ts b/jest.config.ts index 9c5edbc96..0f69ff008 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -67,7 +67,9 @@ const config: Config = { // globalTeardown: undefined, // A set of global variables that need to be available in all test environments - // globals: {}, + globals: { + CONFIG: {}, + }, // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. // maxWorkers: "50%", @@ -91,6 +93,9 @@ const config: Config = { moduleNameMapper: { // Ensures style imports don't break tests "\\.(css|scss)$": "/test/jest/__mocks__/styleMock.ts", + "react-markdown": "/test/jest/__mocks__/react-markdown.tsx", + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/test/jest/__mocks__/fileMock.ts", }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/test/helpers/createSearchClient.ts b/test/helpers/createSearchClient.ts index dfd6fecd2..58699ecdf 100644 --- a/test/helpers/createSearchClient.ts +++ b/test/helpers/createSearchClient.ts @@ -35,7 +35,7 @@ interface Options { * @param options Additional customizations of the search response * @returns */ -export function createSearchClient(options: Options) { +export function createSearchClient(options?: Options) { return { // eslint-disable-next-line @typescript-eslint/no-explicit-any search: (requests: any) => diff --git a/test/jest/__mocks__/fileMock.ts b/test/jest/__mocks__/fileMock.ts new file mode 100644 index 000000000..05072a64d --- /dev/null +++ b/test/jest/__mocks__/fileMock.ts @@ -0,0 +1,4 @@ +// Webpack is not available in tests to handle file imports. Our test config +// tells jest to replace any file imports it sees with this empty string. +const str = ""; +export default str; diff --git a/test/jest/__mocks__/react-markdown.tsx b/test/jest/__mocks__/react-markdown.tsx new file mode 100644 index 000000000..608bd4abd --- /dev/null +++ b/test/jest/__mocks__/react-markdown.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +const ReactMarkdown = ({ + children, +}: { + children: string | JSX.Element | JSX.Element[] | (() => JSX.Element); +}) => { + return <>{children}; +}; + +export default ReactMarkdown;