Group city autocomplete suggestions by city + airports

Match Angular's CityAutocompleteItemComponent: each suggestion is either
a city row (bold name, country in gray) or an indented airport row. Port
CitiesSearchService search (starts-with → includes → by-airport-name,
cap at 10 cities, then insert each city's other airports). Airport
selections resolve to the owning city code, matching Angular behavior
where typing 'SVO' or clicking the Sheremetyevo row sets city = MOW.
This commit is contained in:
2026-04-17 23:00:07 +03:00
parent 3f31ef591c
commit 40fa7c5f06
4 changed files with 237 additions and 43 deletions
@@ -95,6 +95,33 @@
position: relative;
}
.city-autocomplete__item {
white-space: nowrap;
display: flex;
align-items: center;
padding: 8px 12px;
.city {
white-space: nowrap;
font-weight: 700;
color: #222;
}
.country {
white-space: nowrap;
color: #8a8a8a;
}
.airport {
white-space: nowrap;
color: #8a8a8a;
}
&--airport {
padding-left: 28px;
}
}
.tooltip {
color: #e55353;
font-size: 12px;
+30 -43
View File
@@ -1,14 +1,10 @@
import { useState, useRef, useCallback, useEffect, type FC } from "react";
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
import { CityPickerPopup } from "./CityPickerPopup.js";
import { searchCities, type CityAutocompleteItem } from "./searchCities.js";
import type { IDictionaries } from "@/shared/dictionaries/index.js";
import "./CityAutocomplete.scss";
interface CitySuggestion {
code: string;
name: string;
}
export interface CityAutocompleteProps {
label: string;
placeholder: string;
@@ -30,8 +26,8 @@ export const CityAutocomplete: FC<CityAutocompleteProps> = ({
error,
testIdPrefix = "city-autocomplete",
}) => {
const [inputValue, setInputValue] = useState<CitySuggestion | string>(value);
const [suggestions, setSuggestions] = useState<CitySuggestion[]>([]);
const [inputValue, setInputValue] = useState<CityAutocompleteItem | string>(value);
const [suggestions, setSuggestions] = useState<CityAutocompleteItem[]>([]);
const [popupOpen, setPopupOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@@ -55,39 +51,7 @@ export const CityAutocomplete: FC<CityAutocompleteProps> = ({
setSuggestions([]);
return;
}
const q = event.query.trim().toUpperCase();
if (q.length === 0) {
setSuggestions([]);
return;
}
if (q.length === 3) {
const byCity = dictionaries.cityByCode.get(q);
if (byCity) {
setSuggestions([{ code: byCity.code, name: byCity.name }]);
return;
}
}
const lc = event.query.toLowerCase();
const byName = dictionaries.cities
.filter((c) => c.name.toLowerCase().includes(lc))
.slice(0, 15)
.map((c) => ({ code: c.code, name: c.name }));
if (byName.length) {
setSuggestions(byName);
return;
}
const byAirport = dictionaries.airportByCode.get(q);
if (byAirport) {
const city = dictionaries.cityByCode.get(byAirport.city_code.toUpperCase());
if (city) {
setSuggestions([{ code: city.code, name: city.name }]);
return;
}
}
setSuggestions([]);
setSuggestions(searchCities(dictionaries, event.query));
},
[dictionaries],
);
@@ -105,6 +69,25 @@ export const CityAutocomplete: FC<CityAutocompleteProps> = ({
setInputValue("");
}, [onChange]);
const renderSuggestion = useCallback((item: CityAutocompleteItem) => {
if (item.kind === "city") {
return (
<div className="city-autocomplete__item" data-testid={`city-suggestion-${item.code}`}>
<span className="city">{item.name},</span>
<span className="country">&nbsp;{item.countryName}</span>
</div>
);
}
return (
<div
className="city-autocomplete__item city-autocomplete__item--airport"
data-testid={`airport-suggestion-${item.code}`}
>
<span className="airport">{item.name}</span>
</div>
);
}, []);
const selectedCity = dictionaries?.cityByCode.get(value.toUpperCase()) ?? null;
const hasValue = Boolean(selectedCity);
@@ -135,10 +118,14 @@ export const CityAutocomplete: FC<CityAutocompleteProps> = ({
field="name"
minLength={1}
placeholder={placeholder}
onChange={(e) => setInputValue(e.value as CitySuggestion | string)}
itemTemplate={renderSuggestion}
onChange={(e) => setInputValue(e.value as CityAutocompleteItem | string)}
onSelect={(e) => {
const sel = e.value as CitySuggestion;
if (sel?.code) handleSelect(sel.code);
const sel = e.value as CityAutocompleteItem;
if (!sel) return;
const code =
sel.kind === "airport" ? sel.city_code.toUpperCase() : sel.code;
handleSelect(code);
}}
data-testid={`${testIdPrefix}-input`}
inputClassName="input--filter"
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IRawDictionaries } from "@/shared/dictionaries/index.js";
import { searchCities } from "./searchCities.js";
const raw: IRawDictionaries = {
regions: [{ world_region_id: 1, title: { ru: "Россия" } }],
countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 1 }],
cities: [
{ code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } },
{ code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } },
{ code: "MRV", title: { ru: "Минеральные Воды" }, country_code: "RU", has_afl_flights: true, location: { lat: 44, lon: 43 } },
{ code: "OMS", title: { ru: "Омск" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 73 } },
],
airports: [
{ code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
{ code: "VKO", city_code: "MOW", title: { ru: "Внуково" }, has_afl_flights: true, location: { lat: 55, lon: 36 } },
{ code: "DME", city_code: "MOW", title: { ru: "Домодедово" }, has_afl_flights: true, location: { lat: 55, lon: 38 } },
{ code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } },
{ code: "MRV", city_code: "MRV", title: { ru: "Минеральные Воды" }, has_afl_flights: true, location: { lat: 44, lon: 43 } },
{ code: "OMS", city_code: "OMS", title: { ru: "Омск" }, has_afl_flights: true, location: { lat: 55, lon: 73 } },
],
};
const dict = transformDictionaries(raw, "ru");
describe("searchCities", () => {
it("returns [] for empty query", () => {
expect(searchCities(dict, "")).toEqual([]);
expect(searchCities(dict, " ")).toEqual([]);
});
it("returns Moscow + its 3 airports for 'Мос'", () => {
const result = searchCities(dict, "Мос");
expect(result).toHaveLength(4);
expect(result[0]!.kind).toBe("city");
expect(result[0]!.name).toBe("Москва");
expect(result.slice(1).every((x) => x.kind === "airport")).toBe(true);
expect(result.slice(1).map((x) => x.name)).toEqual([
"Внуково",
"Домодедово",
"Шереметьево",
]);
});
it("is case-insensitive", () => {
const lc = searchCities(dict, "мос");
const uc = searchCities(dict, "МОС");
const mixed = searchCities(dict, "МоС");
expect(lc[0]!.name).toBe("Москва");
expect(uc[0]!.name).toBe("Москва");
expect(mixed[0]!.name).toBe("Москва");
});
it("resolves a 3-letter city code to the city + airports", () => {
const result = searchCities(dict, "MOW");
expect(result).toHaveLength(4);
expect(result[0]!.name).toBe("Москва");
});
it("resolves a 3-letter airport code to the owning city + airports", () => {
const result = searchCities(dict, "VKO");
expect(result).toHaveLength(4);
expect(result[0]!.name).toBe("Москва");
});
it("skips the airport whose code matches the city code (LED)", () => {
const result = searchCities(dict, "Санкт");
expect(result).toHaveLength(1);
expect(result[0]!.kind).toBe("city");
expect(result[0]!.name).toBe("Санкт-Петербург");
});
it("combines cities that start with the query + cities that include it", () => {
const result = searchCities(dict, "М");
const cityHits = result.filter((x) => x.kind === "city").map((x) => x.name);
expect(cityHits).toContain("Москва");
expect(cityHits).toContain("Минеральные Воды");
expect(cityHits).toContain("Омск");
});
it("returns [] for an unknown query", () => {
expect(searchCities(dict, "zzzzz")).toEqual([]);
});
});
+95
View File
@@ -0,0 +1,95 @@
import type { IAirport, ICity, IDictionaries } from "@/shared/dictionaries/types.js";
export type CityAutocompleteItem =
| ({ kind: "city" } & ICity)
| ({ kind: "airport" } & IAirport);
const MAX_ITEMS_COUNT = 10;
const CODE_LENGTH = 3;
const byName = <T extends { name: string }>(a: T, b: T): number =>
a.name.localeCompare(b.name, "ru");
function findCitiesStartingWith(dict: IDictionaries, q: string): ICity[] {
const lc = q.toLowerCase();
return dict.cities.filter((c) => c.name.toLowerCase().startsWith(lc)).sort(byName);
}
function findCitiesIncluding(dict: IDictionaries, q: string): ICity[] {
const lc = q.toLowerCase();
return dict.cities.filter((c) => c.name.toLowerCase().includes(lc)).sort(byName);
}
function findCitiesByAirportName(dict: IDictionaries, q: string): ICity[] {
const lc = q.toLowerCase();
const airports = dict.airports
.filter((a) => a.name.toLowerCase().startsWith(lc))
.sort(byName);
const result: ICity[] = [];
for (const a of airports) {
const city = dict.cityByCode.get(a.city_code.toUpperCase());
if (city) result.push(city);
}
return result;
}
function dedupeByCode<T extends { code: string }>(xs: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const x of xs) {
if (seen.has(x.code)) continue;
seen.add(x.code);
out.push(x);
}
return out;
}
function addAirportsToCities(cities: ICity[]): CityAutocompleteItem[] {
const out: CityAutocompleteItem[] = [];
for (const city of cities) {
out.push({ kind: "city", ...city });
const airports = city.airports
.filter((a) => a.code !== city.code)
.sort(byName);
for (const a of airports) out.push({ kind: "airport", ...a });
}
return out;
}
function findCitiesByName(dict: IDictionaries, q: string): ICity[] {
const startsWith = findCitiesStartingWith(dict, q);
if (startsWith.length > MAX_ITEMS_COUNT) return startsWith;
const includes = findCitiesIncluding(dict, q);
const merged = dedupeByCode([...startsWith, ...includes]);
if (merged.length > MAX_ITEMS_COUNT) return merged;
const byAirports = findCitiesByAirportName(dict, q);
const notStartsWith = [...includes, ...byAirports].sort(byName);
return dedupeByCode([...startsWith, ...notStartsWith]);
}
/**
* Mirrors Angular's CitiesSearchService.findCitiesByName (top 10), then inserts
* each city's airports (excluding the airport whose code matches the city code)
* directly after it. Discriminated union lets the renderer draw cities vs
* airports differently.
*/
export function searchCities(dict: IDictionaries, query: string): CityAutocompleteItem[] {
const q = query.trim();
if (!q) return [];
const upper = q.toUpperCase();
if (upper.length === CODE_LENGTH) {
const byCity = dict.cityByCode.get(upper);
if (byCity) return addAirportsToCities([byCity]);
const byAirport = dict.airportByCode.get(upper);
if (byAirport) {
const city = dict.cityByCode.get(byAirport.city_code.toUpperCase());
if (city) return addAirportsToCities([city]);
}
}
const cities = findCitiesByName(dict, q).slice(0, MAX_ITEMS_COUNT);
return addAirportsToCities(cities);
}