Skip to content

Commit

Permalink
Autocomplete for location search (#2182)
Browse files Browse the repository at this point in the history
* feat: initial location autocomplete interface

* fix: convert to use new `LocationSearchSuggestion` type

* feat: fully integrate location into autocomplete and allow selecting

* fix: close autocomplete when selecting location text

* test: basic tests for selecting location autocomplete results

* chore: remove commented-out import

Co-authored-by: Kayla Firestack <[email protected]>

* docs: better explain location autocomplete callbacks

Co-authored-by: Kayla Firestack <[email protected]>

* test: MapDisplay tests

* test: assert that selecting text-only suggestion closes autocomplete

* fix: better HighlightedMatch behavior with symbols in highlight text

* fix: street view icon issue on Safari

* fix: correct return of take_limited_matches/2 with no search terms

* fix: prevent search display from rendering over autocomplete

* fix: URI-encode location search queries

---------

Co-authored-by: Kayla Firestack <[email protected]>
Co-authored-by: Kayla Firestack <[email protected]>
  • Loading branch information
3 people authored Aug 15, 2023
1 parent bf09ccc commit 6debd51
Show file tree
Hide file tree
Showing 16 changed files with 381 additions and 61 deletions.
4 changes: 3 additions & 1 deletion assets/css/_map_page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ $z-map-page-context: (
margin: 0.5rem auto 0;
}

.c-search-display {
.c-map-page .c-search-display {
isolation: isolate;
z-index: -1;
flex: 1 1 auto;
overflow-y: scroll;
padding: 0.625rem 1rem 0 1rem;
Expand Down
4 changes: 4 additions & 0 deletions assets/css/_street_view_button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ $street-view-button-text-color: $color-eggplant-50;

/* Text Decoration */
text-decoration: none;

span svg {
stroke: none;
}
}

/* Override `.leaflet-container a` */
Expand Down
4 changes: 2 additions & 2 deletions assets/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export const fetchLocationSearchResults = (
searchText: string
): Promise<LocationSearchResult[] | null> =>
checkedApiCall<LocationSearchResultData[], LocationSearchResult[] | null>({
url: `api/location_search/search?query=${searchText}`,
url: `api/location_search/search?query=${encodeURIComponent(searchText)}`,
dataStruct: array(LocationSearchResultData),
parser: nullableParser(locationSearchResultsFromData),
defaultResult: [],
Expand All @@ -250,7 +250,7 @@ export const fetchLocationSearchSuggestions = (
LocationSearchSuggestionData[],
LocationSearchSuggestion[] | null
>({
url: `api/location_search/suggest?query=${searchText}`,
url: `api/location_search/suggest?query=${encodeURIComponent(searchText)}`,
dataStruct: array(LocationSearchSuggestionData),
parser: nullableParser(locationSearchSuggestionsFromData),
defaultResult: null,
Expand Down
37 changes: 36 additions & 1 deletion assets/src/components/groupedAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Ghost, Vehicle } from "../realtime"
import { clamp } from "../util/math"
import { formatOperatorNameFromVehicle } from "../util/operatorFormatting"
import { SocketContext } from "../contexts/socketContext"
import { useLocationSearchSuggestions } from "../hooks/useLocationSearchSuggestions"

// #region Autocomplete Control
// #region Cursor Reducer
Expand Down Expand Up @@ -647,7 +648,8 @@ const GroupOptionList = (props: LabelledListProps) => (
*/
interface GroupedAutocompleteFromSearchTextResultsProps
extends GroupedAutocompleteControlRefProps,
Omit<GroupedAutocompleteProps, "optionGroups"> {
Omit<GroupedAutocompleteProps, "optionGroups">,
GroupedAutocompleteFromSearchTextEventProps {
/**
* Text to search to populate the autocomplete options with.
*/
Expand All @@ -660,6 +662,9 @@ interface GroupedAutocompleteFromSearchTextResultsProps
* Max number of options to render in a group.
*/
maxElementsPerGroup?: number
}

export interface GroupedAutocompleteFromSearchTextEventProps {
/**
* Callback when a autocomplete vehicle option is selected.
* @param selectedOption The selected option vehicle
Expand All @@ -672,6 +677,17 @@ interface GroupedAutocompleteFromSearchTextResultsProps
* type deduction.
*/
onSelectVehicleOption: (selectedOption: Vehicle | Ghost) => void

/**
* Fired when a autocomplete option with a PlaceId is selected.
* @param selectedPlaceId Suggested PlaceId.
*/
onSelectedLocationId: (selectedPlaceId: string) => void
/**
* Fired when a autocomplete option without a PlaceId is selected.
* @param selectedLocationText Suggested Location Search Text.
*/
onSelectedLocationText: (selectedLocationText: string) => void
}

/**
Expand All @@ -680,6 +696,8 @@ interface GroupedAutocompleteFromSearchTextResultsProps
*/
export const GroupedAutocompleteFromSearchTextResults = ({
onSelectVehicleOption: onSelectVehicleOptionProp,
onSelectedLocationId,
onSelectedLocationText,
searchText,
searchFilters,
maxElementsPerGroup = 5,
Expand All @@ -697,6 +715,10 @@ export const GroupedAutocompleteFromSearchTextResults = ({
maxElementsPerGroup
)

const locationResults =
useLocationSearchSuggestions(searchFilters.location ? searchText : null) ||
[]

const onSelectVehicleOption = (selectedOption: Vehicle | Ghost) => () => {
onSelectVehicleOptionProp(selectedOption)
}
Expand Down Expand Up @@ -732,6 +754,19 @@ export const GroupedAutocompleteFromSearchTextResults = ({
.slice(0, maxElementsPerGroup)
.map((v) => autocompleteOption(v.runId, onSelectVehicleOption(v)))
),
autocompleteGroup(
<h2>{searchPropertyDisplayConfig.location.name}</h2>,
...locationResults
.slice(0, maxElementsPerGroup)
.map(({ text, placeId }) =>
autocompleteOption(
text,
placeId
? () => onSelectedLocationId(placeId)
: () => onSelectedLocationText(text)
)
)
),
].filter(({ group: { options } }) => options.length > 0)

return <GroupedAutocomplete {...props} optionGroups={groups} />
Expand Down
17 changes: 14 additions & 3 deletions assets/src/components/highlightedMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@ export const HighlightedMatch = ({
? new RegExp(
"(" +
[highlightText, ...highlightText.split(/\s+/)]
.map((s) => highlightRegex(s).source)
.map((s) => highlightRegex(s))
.filter((r): r is RegExp => r !== null)
.map((r) => r.source)
.join("|") +
")",
"i"
)
: highlightRegex(highlightText)

if (regexp === null) {
return <>{content}</>
}

return <HighlightedMatchHelper content={content} regexp={regexp} />
}

Expand Down Expand Up @@ -61,8 +67,13 @@ const HighlightedMatchHelper = ({
)
}

const highlightRegex = (highlightText: string): RegExp => {
const highlightRegex = (highlightText: string): RegExp | null => {
const stripped = filterToAlphanumeric(highlightText)
const allowNonAlphanumeric = intersperseString(stripped, "[^0-9a-zA-Z]*")
return new RegExp(allowNonAlphanumeric, "i")

if (allowNonAlphanumeric === "") {
return null
} else {
return new RegExp(allowNonAlphanumeric, "i")
}
}
22 changes: 20 additions & 2 deletions assets/src/components/mapPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { Socket } from "phoenix"
import SearchResultsByCategory from "./mapPage/searchResultsByCategory"
import { LocationSearchResult } from "../models/locationSearchResult"
import LocationCard from "./mapPage/locationCard"
import { useLocationSearchResultById } from "../hooks/useLocationSearchResultById"

const thereIsAnActiveSearch = (
vehicles: (Vehicle | Ghost)[] | null,
Expand Down Expand Up @@ -176,9 +177,11 @@ const SelectedRoute = ({
const Selection = ({
selectedEntity,
setSelection,
fetchedSelectedLocation,
}: {
selectedEntity: SelectedEntity
setSelection: (selectedEntity: SelectedEntity | null) => void
fetchedSelectedLocation: LocationSearchResult | null
}): ReactElement => {
const [{ searchPageState }, dispatch] = useContext(StateDispatchContext)
const selectRoutePattern = (routePattern: RoutePattern) => {
Expand Down Expand Up @@ -233,11 +236,13 @@ const Selection = ({
selectedRoutePattern={selectedEntity}
selectRoutePattern={selectRoutePattern}
/>
) : (
) : fetchedSelectedLocation ? (
<LocationCard
location={selectedEntity.location}
location={fetchedSelectedLocation}
searchSelection={true}
/>
) : (
<Loading />
)}
</div>
)
Expand All @@ -263,6 +268,17 @@ const MapPage = (): ReactElement<HTMLDivElement> => {
)
// #endregion

const selectedLocationById = useLocationSearchResultById(
selectedEntity?.type === SelectedEntityType.LocationByPlaceId
? selectedEntity.placeId
: null
)

const fetchedSelectedLocation =
selectedEntity?.type === SelectedEntityType.Location
? selectedEntity.location
: selectedLocationById

const setVehicleSelection = useCallback(
(selectedEntity: SelectedEntity | null) => {
switch (selectedEntity?.type) {
Expand Down Expand Up @@ -332,6 +348,7 @@ const MapPage = (): ReactElement<HTMLDivElement> => {
<Selection
selectedEntity={selectedEntity}
setSelection={setVehicleSelection}
fetchedSelectedLocation={fetchedSelectedLocation}
/>
) : (
<SearchMode
Expand All @@ -344,6 +361,7 @@ const MapPage = (): ReactElement<HTMLDivElement> => {
<MapDisplay
selectedEntity={selectedEntity}
setSelection={setVehicleSelection}
fetchedSelectedLocation={fetchedSelectedLocation}
/>
</div>
</div>
Expand Down
21 changes: 18 additions & 3 deletions assets/src/components/mapPage/mapDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ type LiveSelectedEntity =
| null

const useLiveSelectedEntity = (
selectedEntity: SelectedEntity | null
selectedEntity: SelectedEntity | null,
fetchedSelectedLocation: LocationSearchResult | null
): LiveSelectedEntity => {
const { socket } = useContext(SocketContext)

Expand All @@ -146,6 +147,13 @@ const useLiveSelectedEntity = (
return selectedEntity // no live updates for route pattern
case SelectedEntityType.Location:
return selectedEntity
case SelectedEntityType.LocationByPlaceId:
return fetchedSelectedLocation
? {
type: SelectedEntityType.Location,
location: fetchedSelectedLocation,
}
: null
default:
return null
}
Expand Down Expand Up @@ -369,13 +377,17 @@ const SelectionDataLayers = ({
selectedEntity,
setSelection,
setStateClasses,
fetchedSelectedLocation,
}: {
selectedEntity: SelectedEntity | null
setSelection: (selectedEntity: SelectedEntity | null) => void
setStateClasses: (classes: string | undefined) => void
fetchedSelectedLocation: LocationSearchResult | null
}) => {
const liveSelectedEntity: LiveSelectedEntity | null =
useLiveSelectedEntity(selectedEntity)
const liveSelectedEntity: LiveSelectedEntity | null = useLiveSelectedEntity(
selectedEntity,
fetchedSelectedLocation
)

const routePatternIdentifier =
routePatternIdentifierForSelection(liveSelectedEntity)
Expand Down Expand Up @@ -452,10 +464,12 @@ const MapDisplay = ({
selectedEntity,
setSelection,
streetViewInitiallyEnabled = false,
fetchedSelectedLocation,
}: {
selectedEntity: SelectedEntity | null
setSelection: (selectedEntity: SelectedEntity | null) => void
streetViewInitiallyEnabled?: boolean
fetchedSelectedLocation: LocationSearchResult | null
}) => {
const stations = useStations()

Expand Down Expand Up @@ -492,6 +506,7 @@ const MapDisplay = ({
selectedEntity={selectedEntity}
setSelection={setSelection}
setStateClasses={setStateClasses}
fetchedSelectedLocation={fetchedSelectedLocation}
/>
<LayersControl.WithTileContext
setTileType={(tileType: TileType) =>
Expand Down
29 changes: 22 additions & 7 deletions assets/src/components/searchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
isValidSearchText,
searchPropertyDisplayConfig,
} from "../models/searchQuery"
import { Ghost, Vehicle } from "../realtime"
import {
SelectedEntityType,
setOldSearchProperty,
Expand All @@ -26,6 +25,7 @@ import {
import { CircleXIcon } from "./circleXIcon"
import {
GroupedAutocompleteControls,
GroupedAutocompleteFromSearchTextEventProps,
GroupedAutocompleteFromSearchTextResults,
autocompleteOption,
} from "./groupedAutocomplete"
Expand Down Expand Up @@ -68,7 +68,8 @@ type SearchFormEventProps = {
}

type SearchFormProps = SearchFormEventProps &
SearchFormConfigProps & {
SearchFormConfigProps &
GroupedAutocompleteFromSearchTextEventProps & {
/**
* Text to show in the search input box.
*/
Expand All @@ -86,10 +87,6 @@ type SearchFormProps = SearchFormEventProps &
* Callback to run when {@link property} should be updated.
*/
onPropertyChange: (property: SearchPropertyQuery) => void
/**
* Callback to run when a autocomplete vehicle option is selected.
*/
onSelectVehicleOption: (selectedOption: Vehicle | Ghost) => void
}

const allFiltersOn: SearchFiltersState = {
Expand Down Expand Up @@ -160,7 +157,8 @@ export const SearchForm = ({
onClear: onClearProp,
onSubmit: onSubmitProp,
onSelectVehicleOption,

onSelectedLocationId,
onSelectedLocationText,
showAutocomplete: showAutocompleteProp = true,
}: SearchFormProps) => {
const formSearchInput = useRef<HTMLInputElement | null>(null)
Expand Down Expand Up @@ -287,6 +285,11 @@ export const SearchForm = ({
searchText={inputText}
fallbackOption={autocompleteOption(inputText, onSubmit)}
onSelectVehicleOption={onSelectVehicleOption}
onSelectedLocationId={onSelectedLocationId}
onSelectedLocationText={(text) => {
setAutocompleteEnabled(false)
onSelectedLocationText(text)
}}
controllerRef={autocompleteController}
onCursor={{
onCursorExitEdge: () => formSearchInput.current?.focus(),
Expand Down Expand Up @@ -351,6 +354,18 @@ const SearchFormFromStateDispatchContext = ({
})
)
}}
onSelectedLocationId={(id) => {
dispatch(
setSelectedEntity({
type: SelectedEntityType.LocationByPlaceId,
placeId: id,
})
)
}}
onSelectedLocationText={(text) => {
dispatch(setSearchText(text))
dispatch(submitSearch())
}}
/>
)
}
Expand Down
Loading

0 comments on commit 6debd51

Please sign in to comment.