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); +}