Skip to content
2 changes: 1 addition & 1 deletion app/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("<App />", () => {
coords: { lat: 37.7749, lng: -122.4194 },
inSanFrancisco: false,
},
aroundUserLocationRadius: "all",
aroundUserLocationRadius: 1600,
setAroundRadius: expect.any(Function),
setAroundLatLng: expect.any(Function),
});
Expand Down
8 changes: 5 additions & 3 deletions app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export const App = () => {
const location = useLocation();
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
const [aroundLatLng, setAroundLatLng] = useState<string>("");
const [aroundUserLocationRadius, setAroundRadius] = useState<AroundRadius>(
"all" as const
);
const [aroundUserLocationRadius, setAroundRadius] =
useState<AroundRadius>(1600);
const [boundingBox, setBoundingBox] = useState<string | undefined>(undefined);

useEffect(() => {
getLocation().then((userLocation) => {
Expand Down Expand Up @@ -60,6 +60,8 @@ export const App = () => {
setAroundLatLng,
aroundUserLocationRadius,
setAroundRadius,
boundingBox,
setBoundingBox,
};

return (
Expand Down
56 changes: 51 additions & 5 deletions app/components/SearchAndBrowse/SearchMap/SearchMap.tsx

Choose a reason for hiding this comment

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

praise: 😌 Really nice comments in handleSearchThisAreaClick() otherwise those operations are pretty opaque not behind meaningful names.

Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,43 @@ export const SearchMap = ({
null
);
const { userLocation, aroundLatLng } = useAppContext();
const { setAroundLatLng } = useAppContextUpdater();
const { setAroundLatLng, setAroundRadius, setBoundingBox } =
useAppContextUpdater();

// Dynamically calculate search radius based on zoom level

function handleSearchThisAreaClick() {
const center = googleMapObject?.getCenter();
if (center?.lat() && center?.lng()) {
setAroundLatLng(`${center.lat()}, ${center.lng()}`);
const map = googleMapObject;
if (map) {
// Get the visible bounds of the map
const bounds = map.getBounds();
if (bounds) {

Choose a reason for hiding this comment

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

question (if-minor): Out of pure curiosity, did you find that bounds might be falsy sometimes?

const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();

// Format as Algolia expects: "lat1,lng1,lat2,lng2"
// Where (lat1, lng1) is the top-left (NW) corner and (lat2, lng2) is the bottom-right (SE) corner
const boundingBoxString = `${ne.lat()},${sw.lng()},${sw.lat()},${ne.lng()}`;

// Update the bounding box for search
setBoundingBox(boundingBoxString);

// Set aroundRadius to "all" to disable radius-based filtering
setAroundRadius("all");

// Keep center point updated for reference (used for map centering)
const center = map.getCenter();
if (center) {
const centerStr = `${center.lat()}, ${center.lng()}`;
setAroundLatLng(centerStr);
}

// Notify SearchResultsPage component to reset pagination
handleSearchMapAction(SearchMapActions.SearchThisArea);
}
} else {
handleSearchMapAction(SearchMapActions.SearchThisArea);
}
handleSearchMapAction(SearchMapActions.SearchThisArea);
}

const aroundLatLngToMapCenter = {
Expand Down Expand Up @@ -127,6 +156,23 @@ export const SearchMap = ({
// SetMapObject shares the Google Map object across parent/sibling components
// so that they can adjustments to markers, coordinates, layout, etc.,
setMapObject(map);

// Set initial bounding box when map is first loaded
const idleListener = map.addListener("idle", () => {
// Remove the listener so it only fires once
google.maps.event.removeListener(idleListener);

Choose a reason for hiding this comment

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

thought: Man this kind of variable access in JS (via "hoisting") always breaks my brain. What about expanding this comment so folks unfamiliar with gmaps might understand this a little better:

Remove the listener so it only fires once on initialization because  the `idle` event will continue to fire when the map finishes panning, zooming, or loading.


const bounds = map.getBounds();
if (bounds) {
const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();
const boundingBoxString = `${ne.lat()},${sw.lng()},${sw.lat()},${ne.lng()}`;
setBoundingBox(boundingBoxString);

// Notify that map is initialized
handleSearchMapAction(SearchMapActions.MapInitialized);
}
});
}}
options={createMapOptions}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@
.searchResultsHeader {
display: flex;
justify-content: space-between;
align-items: center;
align-items: baseline;

h2 {
font-size: 20px;
font-size: 16px;
}

@media screen and (max-width: $break-tablet-p) {
Expand All @@ -80,7 +80,7 @@
border-bottom: 1px solid $border-gray;

h2 {
font-size: 18px;
font-size: 14px;
}

a {
Expand Down
19 changes: 8 additions & 11 deletions app/components/SearchAndBrowse/SearchResults/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,20 @@ import styles from "./SearchResults.module.scss";
import ClearSearchButton from "../Refinements/ClearSearchButton";
import { Loader } from "components/ui/Loader";
import ResultsPagination from "components/SearchAndBrowse/Pagination/ResultsPagination";
import { SearchResultsHeader } from "components/ui/SearchResultsHeader";

export enum SearchMapActions {
SearchThisArea,
MapInitialized,
}

const SearchResults = ({
mobileMapIsCollapsed,
}: {
mobileMapIsCollapsed: boolean;
}) => {
const { refine: refinePagination } = usePagination();
const { refine: refinePagination, currentRefinement: currentPage } =
usePagination();
const {
// Results type is algoliasearchHelper.SearchResults<SearchHit>
results: searchResults,
Expand Down Expand Up @@ -55,15 +58,6 @@ const SearchResults = ({
</div>
);

const searchResultsHeader = () => {
return (
<div className={styles.searchResultsHeader}>
<h2>{searchResults.nbHits} results</h2>
<ClearSearchButton />
</div>
);
};

const handleAction = (searchMapAction: SearchMapActions) => {
switch (searchMapAction) {
case SearchMapActions.SearchThisArea:
Expand All @@ -83,7 +77,10 @@ const SearchResults = ({
<NoResultsDisplay />
) : (
<>
{searchResultsHeader()}
<SearchResultsHeader
currentPage={currentPage}
totalResults={searchResults.nbHits}
/>
{searchMapHitData.hits.map((hit: TransformedSearchHit, index) => (
<SearchResult
hit={hit}
Expand Down
2 changes: 1 addition & 1 deletion app/components/SearchAndBrowse/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const Sidebar = ({
const { setAroundRadius } = useAppContextUpdater();

useClickOutside(
filterMenuRef,
filterMenuRef as React.RefObject<HTMLElement>,
() => setFilterMenuVisible(false),
filterMenuVisible
);
Expand Down
2 changes: 1 addition & 1 deletion app/components/ui/Navigation/MobileNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const MobileNavigation = ({ menuData }: MobileNavigationProps) => {
const mobileNavTextDisplay = mobileNavigationIsOpen ? "Close" : "Menu";

useClickOutside(
filterMenuRef,
filterMenuRef as React.RefObject<HTMLElement>,
(event: MouseEvent) => {
// Prevents collison between handling the outside click and clicking on the
// "Close" button
Expand Down
30 changes: 26 additions & 4 deletions app/components/ui/SearchResultsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import React, { ReactNode } from "react";
import React from "react";
import styles from "components/SearchAndBrowse/SearchResults/SearchResults.module.scss";
import ClearSearchButton from "components/SearchAndBrowse/Refinements/ClearSearchButton";
import { HITS_PER_PAGE } from "pages/SearchResultsPage/SearchResultsPage";

/**
* 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>
);
export const SearchResultsHeader = ({
currentPage,
totalResults,
}: {
currentPage: number;
totalResults: number;
}) => {
const firstResultIndex = currentPage * HITS_PER_PAGE + 1;
const lastResultIndex = Math.min(
(currentPage + 1) * HITS_PER_PAGE,
totalResults
);
return (
<div className={styles.searchResultsHeader}>
<h2 style={{ fontWeight: 500, fontSize: 16 }}>
Showing <span style={{ fontWeight: 700 }}>{firstResultIndex}</span> -{" "}
<span style={{ fontWeight: 700 }}>{lastResultIndex}</span> of{" "}
<span style={{ fontWeight: 700 }}>{totalResults}</span> results
</h2>
<ClearSearchButton />
</div>
);
};
10 changes: 6 additions & 4 deletions app/pages/BrowseResultsPage/BrowseResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export const BrowseResultsPage = () => {
results: searchResults,
status,
} = useInstantSearch();
const { refine: refinePagination } = usePagination();
const { refine: refinePagination, currentRefinement: currentPage } =
usePagination();
const { refine: clearRefinements } = useClearRefinements();

useEffect(() => window.scrollTo(0, 0), []);
Expand Down Expand Up @@ -131,9 +132,10 @@ export const BrowseResultsPage = () => {
<h2 className="sr-only">Search results</h2>
<>
{/* This is browse not search */}
<SearchResultsHeader>
<h2>{searchResults.nbHits} results</h2>
</SearchResultsHeader>
<SearchResultsHeader
currentPage={currentPage}
totalResults={searchResults.nbHits}
/>
{searchMapHitData.hits.map(
(hit: TransformedSearchHit, index) => (
<SearchResult
Expand Down
15 changes: 15 additions & 0 deletions app/pages/SearchResultsPage/SearchResultsPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,18 @@
// for the query to be passed to the Algolia Instant Search internals.
display: none;
}

.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;

p {
margin-top: 1rem;
font-size: 1.1rem;
color: #666;
}
}
72 changes: 61 additions & 11 deletions app/pages/SearchResultsPage/SearchResultsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,73 @@
import { render, screen, waitFor } from "@testing-library/react";
import { SearchResultsPage } from "pages/SearchResultsPage/SearchResultsPage";
import { createSearchClient } from "../../../test/helpers/createSearchClient";
import { AppProvider } from "utils/useAppContext";
import { COORDS_MID_SAN_FRANCISCO } from "utils";

// Mock the SearchMap component to avoid Google Maps rendering issues in tests
jest.mock("components/SearchAndBrowse/SearchMap/SearchMap", () => {
// Import React inside the mock factory
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mockReact = require("react");

return {
SearchMap: ({ handleSearchMapAction }: any) => {

Check warning on line 16 in app/pages/SearchResultsPage/SearchResultsPage.test.tsx

View workflow job for this annotation

GitHub Actions / build_and_test_app

Unexpected any. Specify a different type
// Simulate map initialization - MapInitialized = 1
mockReact.useEffect(() => {
handleSearchMapAction(1);
}, [handleSearchMapAction]);

return mockReact.createElement(
"div",
{ "data-testid": "search-map-mock" },
"Search Map"
);
},
};
});

// Test wrapper with AppProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => {
const [aroundLatLng, setAroundLatLng] = React.useState("");
const [aroundRadius, setAroundRadius] = React.useState<"all" | number>(1600);
const [boundingBox, setBoundingBox] = React.useState<string | undefined>();

return (
<AppProvider
userLocation={{
coords: COORDS_MID_SAN_FRANCISCO,
inSanFrancisco: false,
}}
aroundLatLng={aroundLatLng}
setAroundLatLng={setAroundLatLng}
aroundUserLocationRadius={aroundRadius}
setAroundRadius={setAroundRadius}
boundingBox={boundingBox}
setBoundingBox={setBoundingBox}
>
{children}
</AppProvider>
);
};

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>
<TestWrapper>
<InstantSearch
searchClient={searchClient}
indexName="fake_test_search_index"
initialUiState={{
fake_test_search_index: {
query: "fake query",
},
}}
>
<SearchResultsPage />
</InstantSearch>
</TestWrapper>
);

await waitFor(() => {
Expand Down
Loading
Loading