Having the ability to auto-suggest results as the user types is pretty much expected behavior these days. Below, we walk through the basic steps of how to implement a city-auto-complete feature.
Basically, as the user begins typing a city name, we want to get back a list of possible matches. The user should then be able to select from one of the listed options.
This tutorial assumes a basic knowledge of:
Having said that, the core logic and concepts will be based on ReactiveX, so it should be fairly straightforward to apply them to any environment with a ReactiveX implementation.
Using the GeoDB Angular SDK, we leverage the GeoDbService.findCities method to return an Observable over the cities, with the namePrefix filter applied.
Our initial code will look something like this:
this.cityAutoSuggestions: Observable<CitySummary>[]> = this.geoDbService.findCities({
namePrefix: cityNamePrefix,
minPopulation: 100000,
sortDirectives: [
"-population"
],
limit: 5,
offset: 0
})
.pipe(
map(
(response: GeoResponse) => response.data,
(error: any) => console.log(error)
)
);
This code takes a cityNamePrefix string representing the beginning of the city name to match on and creates an Observable on all cities matching the following:
In addition:
One way to capture the user input is to define an Observable on all such events, then use that Observable to bootstrap our autocomplete pipeline - the output of which is also an Observable.
If you’re using Angular, this Observable is already exposed as the FormControl.valueChanges property. When connected to a text input field, the valueChanges Observable will emit an event reflecting the current value of the input every time the user presses a key.
Let’s change our code to take advantage of this:
this.cityControl = new FormControl();
this.cityAutoSuggestions: Observable<CitySummary>[]> = this.cityControl.valueChanges
.pipe(
map( (cityNamePrefix: string) => {
let cities: Observable<CitySummary[]> = this.geoDbService.findCities({
namePrefix: cityNamePrefix,
minPopulation: 100000,
sortDirectives: [
"-population"
],
limit: 5,
offset: 0
})
.pipe(
map(
(response: GeoResponse) => response.data,
(error: any) => console.log(error)
)
);
return cities;
})
);
Great! Now every time the user presses a key, the cityControl.valueChanges Observable will emit the updated field value as an event. This field value will then become the current cityNamePrefix, which then gets mapped to the Observable returned from the findCities method as before.
If you think that seemed too easy, you are unfortunately right.
What happens if the user begins typing, then suddenly decides they made a mistake and deletes to start over? Obviously, we want to avoid even creating our findCities Observable for a blank string. In fact, what we probably want is to avoid creating this Observable for any input whose length is less than our specified minimum. Would it really make sense to try to generate an autocomplete list for a single letter?
Let’s change the code as follows:
this.cityControl = new FormControl();
this.cityAutoSuggestions: Observable<CitySummary>[]> = this.cityControl.valueChanges
.pipe(
map( (cityNamePrefix: string) => {
let cities: Observable<CitySummary[]> = of([]);
if (cityNamePrefix && cityNamePrefix.length >= 3) {
cities = this.geoDbService.findCities({
namePrefix: cityNamePrefix,
minPopulation: 100000,
sortDirectives: [
"-population"
],
limit: 5,
offset: 0
})
.pipe(
map(
(response: GeoResponse) => response.data,
(error: any) => console.log(error)
)
);
}
return cities;
})
);
With this change, we initially create our cities Observable over an empty array. If the current cityNamePrefix is undefined or less than three characters long, we short-circuit out. Otherwise, we create the findCities Observable as before.
What happens if the user types so fast that our Observable pipeline can’t finish processing one event before the next comes on its heels? We want to somehow exclude all but the most recent valueChanges events.
This is easily done by taking advantage of the Observable switchMap operator:
this.cityControl = new FormControl();
this.cityAutoSuggestions: Observable<CitySummary>[]> = this.cityControl.valueChanges
.pipe(
switchMap( (cityNamePrefix: string) => {
let cities: Observable<CitySummary[]> = of([]);
if (cityNamePrefix && cityNamePrefix.length >= 3) {
cities = this.geoDbService.findCities({
namePrefix: cityNamePrefix,
minPopulation: 100000,
sortDirectives: [
"-population"
],
limit: 5,
offset: 0
})
.pipe(
map(
(response: GeoResponse) => response.data,
(error: any) => console.log(error)
)
);
}
return cities;
})
);
Here, there are many ways to skin a cat. All you really need is an autocomplete widget that ties a user input field with a list of suggestions.
In this tutorial, we assume you’re using Angular and will demonstrate this with the excellent Angular Material Autocomplete component. After going through the below steps, you should be able to easily adapt the techniques presented to your specific framework and widget.
From the Angular Material Autocomplete overview page, we have the following snippet:
<mat-form-field>
<input type="text" matInput [formControl]="myControl" [matAutocomplete]="auto">
</mat-form-field>
<mat-autocomplete #auto="matAutocomplete">
<mat-option *ngFor="let option of options" [value]="option">
{{ option }}
</mat-option>
</mat-autocomplete>
This says the following:
To adapt this snippet to our specific case, we really only have to do two things:
<mat-form-field>
<input type="text" matInput [formControl]="cityControl" [matAutocomplete]="auto">
</mat-form-field>
<mat-autocomplete #auto="matAutocomplete" [displayWith]="getCityDisplayName">
<mat-option *ngFor="let city of cityAutoSuggestions | async" [value]="city">
{{getCityDisplayName(city)}}
</mat-option>
</mat-autocomplete>
Some differences to note:
getCityDisplayName(city: CitySummary) {
if (!city) {
return null;
}
let name = city.city;
if (city.region) {
name += ", " + city.region;
}
name += ", " + city.country;
return name;
}
You can see the running example here and the corresponding source code here.
Now go get yourself a proper latte with a chocolate croissant. You’ve earned it!