Skip to content

Commit

Permalink
CM-1005: Add autocomplete near a city field (#1050)
Browse files Browse the repository at this point in the history
* CM-1004: Update error message for no search result

* CM-1007: Add end of results text to find a park page

* CM-1005: Add autocomplete near a city field component

* CM-1008: Add current location functionality

* CM-1005: Add autocomplete near a city field to find a park page

* CM-1008: Add location permission blocked toast

* CM-1010: Update search text field styling

* CM-1006: Update search section visual on home page and find a park page

* CM-1005: Add new param near for near a city search

* CM-1009: Update search results text on find a park page

* CM-1004: Update search error page

* CM-1039: Update search text fields based on UX review

* CM-1005: Update error message for near a city field

* CM-1005: Code refactor

* CM-1005: Remove unnecessary code

* CM-1005: Update city name option sorting

* CM-1005: Open dropdown before user types text in near a city field

* CM-1005: Update city name options sort order

* CM-1005: Update city name options filtering

* CM-1005: Fix an issue that park name remains even user clicks remove park name button on error page

* CM-1005: Fix an issue that city search does not work right away when user selects city

* CM-1005: Fix an issue that city search does not work right away when user selects city on home page

* CM-1005: Fix park name search and city name search errors on home page and find a park page

* CM-1005: Display strapi id as location in address bar

* CM-1005: Make search URLs bookmarkable

* CM-1005: Make search URLs bookmarkable when user selects current location
  • Loading branch information
ayumi-oxd authored Nov 6, 2023
1 parent 6444abc commit 025ef43
Show file tree
Hide file tree
Showing 12 changed files with 1,181 additions and 485 deletions.
2 changes: 1 addition & 1 deletion src/gatsby/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/gatsby/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"prop-types": "^15.7.2",
"qs": "^6.11.1",
"react": "^18.2.0",
"react-bootstrap": "^1.6.6",
"react-bootstrap": "^1.6.7",
"react-bootstrap-typeahead": "^6.2.3",
"react-device-detect": "^2.0.1",
"react-dom": "^18.2.0",
Expand Down
224 changes: 224 additions & 0 deletions src/gatsby/src/components/search/cityNameSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import React, { useState, useEffect, useRef } from "react"
import { graphql, useStaticQuery } from "gatsby"
import { Typeahead, ClearButton, Menu, MenuItem } from "react-bootstrap-typeahead"
import { Form } from "react-bootstrap"
import PermissionToast from "./permissionToast"
import NearMeIcon from "@mui/icons-material/NearMe"
import "react-bootstrap-typeahead/css/Typeahead.css"

const HighlightText = ({ city, input }) => {
const words = city.split(" ")
return (
words.map((word, index) => {
if (word.toLowerCase() === input) {
return <span key={index}> {word} </span>
} else {
return <b key={index}> {word} </b>
}
})
)
}

const CityNameSearch = ({
isCityNameLoading, showPosition, currentLocation, optionLimit, selectedItems, handleChange, handleClick, handleKeyDown
}) => {
const data = useStaticQuery(graphql`
query {
allStrapiSearchCity(
sort: {rank: ASC},
filter: {rank: {lte: 4}}
) {
nodes {
strapi_id
cityName
latitude
longitude
rank
}
}
}
`)

// useState and constants
const [cityText, setCityText] = useState("")
const [hasResult, setHasResult] = useState(false)
const [hasPermission, setHasPermission] = useState(true)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const cities = data?.allStrapiSearchCity?.nodes || []
const typeaheadRef = useRef(null)

// functions
const cityOptions = (optionLimit) => {
const cityTextLower = cityText.toLowerCase()
const filteredCities = cities.filter(city =>
city.cityName.toLowerCase().startsWith(cityTextLower) || city.cityName.toLowerCase().includes(` ${cityTextLower}`)
)
const sortedCities = filteredCities.slice().sort((a, b) => {
if (a.cityName.toLowerCase().startsWith(cityTextLower) && !b.cityName.toLowerCase().startsWith(cityTextLower)) {
return -1
} else if (!a.cityName.toLowerCase().startsWith(cityTextLower) && b.cityName.toLowerCase().startsWith(cityTextLower)) {
return 1
} else if (b.rank !== a.rank) {
return b.rank > a.rank ? -1 : 1
} else {
return a.cityName.localeCompare(b.cityName)
}
})
return cityText ? sortedCities.slice(0, optionLimit) : []
}
const checkResult = (text) => {
const cityTextLower = text.toLowerCase()
const results = cities.filter(city =>
city.cityName.toLowerCase().startsWith(cityTextLower) || city.cityName.toLowerCase().includes(` ${cityTextLower}`)
)
if (results.length > 0) {
setHasResult(true)
} else {
setHasResult(false)
}
}
const showError = (error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
setHasPermission(false)
console.log("User denied the request for Geolocation.")
break
case error.POSITION_UNAVAILABLE:
console.log("Location information is unavailable.")
break
case error.TIMEOUT:
console.log("The request to get user location timed out.")
break
case error.UNKNOWN_ERROR:
console.log("An unknown error occurred.")
break
default:
console.log("An unspecified error occurred.")
}
}

// event handlers
const handleInputChange = (text) => {
if (text.length) {
setCityText(text)
checkResult(text)
}
}
const handleClickGetLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(showPosition, showError)
} else {
console.log("Geolocation is not supported by your browser")
}
}
const handleKeyDownGetLocation = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleClickGetLocation()
}
}
const handleClickInput = () => {
setIsDropdownOpen(!isDropdownOpen)
}

// useEffect
useEffect(() => {
const handleClickOutside = (event) => {
if (typeaheadRef.current && !typeaheadRef.current.inputNode.contains(event.target)) {
setIsDropdownOpen(false)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [])

return (
<>
{!hasPermission && <PermissionToast />}
<Typeahead
ref={typeaheadRef}
id="city-search-typehead"
minLength={1}
isLoading={isCityNameLoading}
labelKey={city => `${city.cityName}`}
options={cityOptions(optionLimit)}
selected={selectedItems}
onChange={handleChange}
onInputChange={handleInputChange}
open={isDropdownOpen}
onToggle={(isOpen) => setIsDropdownOpen(isOpen)}
placeholder=" "
className={`has-text--${cityText.length > 0 ? 'true' : 'false'
} has-error--${(cityText.length > 0 && !hasResult) ? 'true' : 'false'
} city-search-typeahead`}
renderInput={({ inputRef, referenceElementRef, ...inputProps }) => {
return (
<Form.Group controlId="city-search-typeahead">
<Form.Control
{...inputProps}
ref={(node) => {
inputRef(node)
referenceElementRef(node)
}}
onClick={handleClickInput}
/>
<label htmlFor="city-search-typeahead">
Near a city
</label>
</Form.Group>
)
}}
renderMenu={cities => (
<Menu id="city-search-typeahead">
{cities.map((city, index) => (
<MenuItem option={city} position={index} key={index}>
<HighlightText
city={city.cityName}
input={cityText}
/>
</MenuItem>
))}
{(!hasResult && cityText) &&
<MenuItem position={cities.length} key={cities.length} className="no-suggestion-text">
No suggestions, please check your spelling or try a larger city in B.C.
</MenuItem>
}
<MenuItem option={currentLocation} position={cities.length + 1} key={cities.length + 1}>
<div
role="button"
tabIndex="0"
onClick={handleClickGetLocation}
onKeyDown={(e) => handleKeyDownGetLocation(e)}
>
<NearMeIcon />{currentLocation.cityName}
</div>
</MenuItem>
</Menu>
)}
>
{({ onClear, selected }) =>
(!!selected.length || cityText?.length > 0) && (
<div className="rbt-aux">
<ClearButton
onClick={() => {
onClear()
handleClick()
setCityText("")
}}
onKeyDown={(e) => {
onClear()
handleKeyDown(e)
setCityText("")
}}
/>
</div>
)
}
</Typeahead>
</>
)
}

export default CityNameSearch
88 changes: 78 additions & 10 deletions src/gatsby/src/components/search/mainSearch.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,65 @@
import React, { useState } from "react"
import React, { useState, useEffect } from "react"
import { navigate } from "gatsby"
import { Button } from "@mui/material"
import ParkNameSearch from "./parkNameSearch"
import CityNameSearch from "./cityNameSearch"
import { useScreenSize } from "../../utils/helpers"
import "../../styles/search.scss"

const MainSearch = () => {

// useState
const [inputText, setInputText] = useState("")
const [searchText, setSearchText] = useState("")
const [isCityNameLoading, setIsCityNameLoading] = useState(false)
const [selectedCity, setSelectedCity] = useState([])
const [currentLocation, setCurrentLocation] = useState({
strapi_id: 0,
cityName: "Current location",
latitude: 0,
longitude: 0,
rank: 1
})

// functions
const searchParkFilter = () => {
navigate("/find-a-park", {
navigate(`/find-a-park`, {
state: {
searchText
"searchText": searchText || inputText,
"qsLocation":
selectedCity.length > 0 ? selectedCity[0].strapi_id.toString() : "",
"qsCity": selectedCity
},
})
}
const showPosition = (position) => {
setCurrentLocation(currentLocation => ({
...currentLocation,
latitude: position.coords.latitude,
longitude: position.coords.longitude
}))
}

// event handlers
const handleSearchNameChange = (selected) => {
if (selected.length) {
setSearchText(selected[0]?.protectedAreaName)
searchParkFilter()
}
}
const handleSearchNameInputChange = (text) => {
if (text.length) {
setSearchText(text)
setInputText(text)
}
}
const handleKeyDownSearchPark = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
searchParkFilter()
}
}
const handleClickClear = () => {
setInputText("")
setSearchText("")
setSelectedCity([])
}
const handleKeyDownClear = (e) => {
if (e.key === "Enter" || e.key === " ") {
Expand All @@ -37,22 +68,59 @@ const MainSearch = () => {
}
}

// useEffect
useEffect(() => {
if (searchText || (selectedCity.length > 0 &&
(selectedCity[0].latitude !== 0 && selectedCity[0].longitude !== 0)
)) {
setIsCityNameLoading(false)
searchParkFilter()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText, selectedCity])

useEffect(() => {
if (selectedCity.length > 0) {
if (selectedCity[0].latitude === 0 || selectedCity[0].longitude === 0) {
setIsCityNameLoading(true)
}
if (currentLocation.latitude !== 0 || currentLocation.longitude !== 0) {
setSelectedCity([currentLocation])
if (currentLocation === selectedCity[0]) {
searchParkFilter()
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCity, currentLocation])

return (
<div className="parks-search-wrapper">
<h1 className="text-white">Find a park</h1>
<div className="parks-search-field">
<ParkNameSearch
optionLimit={8}
optionLimit={useScreenSize().width > 767 ? 7 : 4}
searchText={inputText}
handleChange={handleSearchNameChange}
handleInputChange={handleSearchNameInputChange}
handleKeyDownSearch={handleKeyDownSearchPark}
handleClick={handleClickClear}
handleKeyDown={handleKeyDownClear}
/>
<span className="or-span">or</span>
<CityNameSearch
isCityNameLoading={isCityNameLoading}
showPosition={showPosition}
currentLocation={currentLocation}
optionLimit={useScreenSize().width > 767 ? 7 : 4}
selectedItems={selectedCity}
handleChange={setSelectedCity}
handleClick={handleClickClear}
handleKeyDown={handleKeyDownClear}
searchText={searchText}
/>
<Button
variant="contained"
onClick={searchParkFilter}
className="parks-search-button"
onClick={searchParkFilter}
>
Search
</Button>
Expand Down
Loading

0 comments on commit 025ef43

Please sign in to comment.