From 40fa7c5f06c343c9b501ecffee8ff1735c25cb17 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 17 Apr 2026 23:00:07 +0300 Subject: [PATCH] Group city autocomplete suggestions by city + airports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../city-autocomplete/CityAutocomplete.scss | 27 ++++++ src/ui/city-autocomplete/CityAutocomplete.tsx | 73 ++++++-------- src/ui/city-autocomplete/searchCities.test.ts | 85 +++++++++++++++++ src/ui/city-autocomplete/searchCities.ts | 95 +++++++++++++++++++ 4 files changed, 237 insertions(+), 43 deletions(-) create mode 100644 src/ui/city-autocomplete/searchCities.test.ts create mode 100644 src/ui/city-autocomplete/searchCities.ts diff --git a/src/ui/city-autocomplete/CityAutocomplete.scss b/src/ui/city-autocomplete/CityAutocomplete.scss index 5f0c57c2..8ec1e0aa 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.scss +++ b/src/ui/city-autocomplete/CityAutocomplete.scss @@ -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; diff --git a/src/ui/city-autocomplete/CityAutocomplete.tsx b/src/ui/city-autocomplete/CityAutocomplete.tsx index e9df234c..0a4f8543 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.tsx +++ b/src/ui/city-autocomplete/CityAutocomplete.tsx @@ -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 = ({ error, testIdPrefix = "city-autocomplete", }) => { - const [inputValue, setInputValue] = useState(value); - const [suggestions, setSuggestions] = useState([]); + const [inputValue, setInputValue] = useState(value); + const [suggestions, setSuggestions] = useState([]); const [popupOpen, setPopupOpen] = useState(false); const rootRef = useRef(null); @@ -55,39 +51,7 @@ export const CityAutocomplete: FC = ({ 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 = ({ setInputValue(""); }, [onChange]); + const renderSuggestion = useCallback((item: CityAutocompleteItem) => { + if (item.kind === "city") { + return ( +
+ {item.name}, +  {item.countryName} +
+ ); + } + return ( +
+ {item.name} +
+ ); + }, []); + const selectedCity = dictionaries?.cityByCode.get(value.toUpperCase()) ?? null; const hasValue = Boolean(selectedCity); @@ -135,10 +118,14 @@ export const CityAutocomplete: FC = ({ 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" diff --git a/src/ui/city-autocomplete/searchCities.test.ts b/src/ui/city-autocomplete/searchCities.test.ts new file mode 100644 index 00000000..4dea5fcb --- /dev/null +++ b/src/ui/city-autocomplete/searchCities.test.ts @@ -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([]); + }); +}); diff --git a/src/ui/city-autocomplete/searchCities.ts b/src/ui/city-autocomplete/searchCities.ts new file mode 100644 index 00000000..0439b3d3 --- /dev/null +++ b/src/ui/city-autocomplete/searchCities.ts @@ -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 = (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(xs: T[]): T[] { + const seen = new Set(); + 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); +}