From 5fc67f81bd070526f982dbf16cb38d5b255f81c5 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 21:32:39 +0300 Subject: [PATCH] Wire city autocomplete to dictionary API useCitySearch hook loads cities from /api/dictionary/1/cities on first use, then searches in-memory by name prefix and code -- matching the Angular CitiesSearchService behavior. Wired into OnlineBoardFilter, ScheduleStartPage, and FlightsMapFilter AutoComplete components. --- .../components/FlightsMapFilter.tsx | 37 +++--- .../components/OnlineBoardFilter.tsx | 45 ++++--- .../components/OnlineBoardStartPage.test.tsx | 4 + .../schedule/components/ScheduleStartPage.tsx | 37 +++--- src/shared/hooks/useCitySearch.ts | 122 ++++++++++++++++++ .../online-board/start-page.test.tsx | 4 + 6 files changed, 201 insertions(+), 48 deletions(-) create mode 100644 src/shared/hooks/useCitySearch.ts diff --git a/src/features/flights-map/components/FlightsMapFilter.tsx b/src/features/flights-map/components/FlightsMapFilter.tsx index b318dfa3..79832c42 100644 --- a/src/features/flights-map/components/FlightsMapFilter.tsx +++ b/src/features/flights-map/components/FlightsMapFilter.tsx @@ -11,6 +11,7 @@ import { type FC, useState, useCallback, type FormEvent } from "react"; import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; import { Calendar } from "primereact/calendar"; import { useTranslation } from "@/i18n/provider.js"; +import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch.js"; import type { IFlightsMapFilterState } from "../types.js"; export interface FlightsMapFilterProps { @@ -43,23 +44,25 @@ export const FlightsMapFilter: FC = ({ onChange, }) => { const { t } = useTranslation(); - const [departure, setDeparture] = useState(value.departure ?? ""); - const [arrival, setArrival] = useState(value.arrival ?? ""); + const [departure, setDeparture] = useState(value.departure ?? ""); + const [arrival, setArrival] = useState(value.arrival ?? ""); - // AutoComplete suggestions (populated by API in future; empty for now) - const [departureSuggestions, setDepartureSuggestions] = useState([]); - const [arrivalSuggestions, setArrivalSuggestions] = useState([]); + // City autocomplete search + const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch(); + const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch(); - const handleDepartureSearch = useCallback((_event: AutoCompleteCompleteEvent) => { - setDepartureSuggestions([]); - }, []); + const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => { + void searchDeparture(event.query); + }, [searchDeparture]); - const handleArrivalSearch = useCallback((_event: AutoCompleteCompleteEvent) => { - setArrivalSuggestions([]); - }, []); + const handleArrivalSearch = useCallback((event: AutoCompleteCompleteEvent) => { + void searchArrival(event.query); + }, [searchArrival]); const handleDepartureBlur = useCallback(() => { - const code = departure.trim().toUpperCase(); + const code = typeof departure === "string" + ? departure.trim().toUpperCase() + : departure.code; if (code !== value.departure) { onChange({ ...value, departure: code || undefined, arrival: undefined }); setArrival(""); @@ -67,7 +70,9 @@ export const FlightsMapFilter: FC = ({ }, [departure, value, onChange]); const handleArrivalBlur = useCallback(() => { - const code = arrival.trim().toUpperCase(); + const code = typeof arrival === "string" + ? arrival.trim().toUpperCase() + : arrival.code; if (code !== value.arrival) { onChange({ ...value, arrival: code || undefined }); } @@ -131,7 +136,8 @@ export const FlightsMapFilter: FC = ({ value={departure} suggestions={departureSuggestions} completeMethod={handleDepartureSearch} - onChange={(e) => setDeparture(e.value as string)} + field="name" + onChange={(e) => setDeparture(e.value as CitySuggestion | string)} onBlur={handleDepartureBlur} placeholder={t("FLIGHTS-MAP.FILTER_DEPARTURE_PLACEHOLDER")} className="input--filter" @@ -157,7 +163,8 @@ export const FlightsMapFilter: FC = ({ value={arrival} suggestions={arrivalSuggestions} completeMethod={handleArrivalSearch} - onChange={(e) => setArrival(e.value as string)} + field="name" + onChange={(e) => setArrival(e.value as CitySuggestion | string)} onBlur={handleArrivalBlur} placeholder={t("FLIGHTS-MAP.FILTER_ARRIVAL_PLACEHOLDER")} className="input--filter" diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index f6887a53..5a7620fd 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -11,6 +11,7 @@ import { useNavigate, useParams } from "@modern-js/runtime/router"; import { Calendar } from "primereact/calendar"; import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; import { useTranslation } from "@/i18n/provider.js"; +import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch.js"; import { buildOnlineBoardUrl } from "../url.js"; import "./OnlineBoardFilter.scss"; @@ -36,23 +37,21 @@ export const OnlineBoardFilter: FC = () => { const [flightDate, setFlightDate] = useState(new Date()); // Route fields - const [departureAirport, setDepartureAirport] = useState(""); - const [arrivalAirport, setArrivalAirport] = useState(""); + const [departureAirport, setDepartureAirport] = useState(""); + const [arrivalAirport, setArrivalAirport] = useState(""); const [routeDate, setRouteDate] = useState(new Date()); - // AutoComplete suggestions (populated by API in future; empty for now) - const [departureSuggestions, setDepartureSuggestions] = useState([]); - const [arrivalSuggestions, setArrivalSuggestions] = useState([]); + // City autocomplete search + const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch(); + const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch(); - const handleDepartureSearch = useCallback((_event: AutoCompleteCompleteEvent) => { - // TODO: call dictionary API to filter cities by query - setDepartureSuggestions([]); - }, []); + const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => { + void searchDeparture(event.query); + }, [searchDeparture]); - const handleArrivalSearch = useCallback((_event: AutoCompleteCompleteEvent) => { - // TODO: call dictionary API to filter cities by query - setArrivalSuggestions([]); - }, []); + const handleArrivalSearch = useCallback((event: AutoCompleteCompleteEvent) => { + void searchArrival(event.query); + }, [searchArrival]); const handleTabClick = useCallback( (tab: FilterTab) => { @@ -89,12 +88,20 @@ export const OnlineBoardFilter: FC = () => { e.preventDefault(); if (!routeDate) return; const dateParam = dateToYyyymmdd(routeDate); - if (!departureAirport.trim() || !arrivalAirport.trim()) return; + + const depCode = typeof departureAirport === "string" + ? departureAirport.trim().toUpperCase() + : departureAirport.code; + const arrCode = typeof arrivalAirport === "string" + ? arrivalAirport.trim().toUpperCase() + : arrivalAirport.code; + + if (!depCode || !arrCode) return; const url = buildOnlineBoardUrl({ type: "route", - departure: departureAirport.trim().toUpperCase(), - arrival: arrivalAirport.trim().toUpperCase(), + departure: depCode, + arrival: arrCode, date: dateParam, }); void navigate(`/${lang}/${url}`); @@ -218,7 +225,8 @@ export const OnlineBoardFilter: FC = () => { value={departureAirport} suggestions={departureSuggestions} completeMethod={handleDepartureSearch} - onChange={(e) => setDepartureAirport(e.value as string)} + field="name" + onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)} placeholder={t("SHARED.CITY_PLACEHOLDER")} className="input--filter" inputClassName="input--filter" @@ -233,7 +241,8 @@ export const OnlineBoardFilter: FC = () => { value={arrivalAirport} suggestions={arrivalSuggestions} completeMethod={handleArrivalSearch} - onChange={(e) => setArrivalAirport(e.value as string)} + field="name" + onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)} placeholder={t("SHARED.CITY_PLACEHOLDER")} className="input--filter" inputClassName="input--filter" diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx index 82ee81db..96997282 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx @@ -28,6 +28,10 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () => PopularRequestsPanel: () =>
Popular
, })); +vi.mock("@/shared/hooks/useCitySearch.js", () => ({ + useCitySearch: () => ({ suggestions: [], search: vi.fn() }), +})); + describe("OnlineBoardStartPage", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index dabbef04..7b62ec72 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -12,6 +12,7 @@ import { useNavigate, useParams } from "@modern-js/runtime/router"; import { Calendar } from "primereact/calendar"; import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; import { useTranslation } from "@/i18n/provider.js"; +import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; import { PageTabs } from "@/ui/layout/PageTabs.js"; import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js"; @@ -40,32 +41,36 @@ export const ScheduleStartPage: FC = () => { const today = new Date(); - const [departureAirport, setDepartureAirport] = useState(""); - const [arrivalAirport, setArrivalAirport] = useState(""); + const [departureAirport, setDepartureAirport] = useState(""); + const [arrivalAirport, setArrivalAirport] = useState(""); const [dateFrom, setDateFrom] = useState(today); const [dateTo, setDateTo] = useState(addDays(today, 7)); const [isRoundTrip, setIsRoundTrip] = useState(false); const [returnDateFrom, setReturnDateFrom] = useState(addDays(today, 7)); const [returnDateTo, setReturnDateTo] = useState(addDays(today, 14)); - // AutoComplete suggestions (populated by API in future; empty for now) - const [departureSuggestions, setDepartureSuggestions] = useState([]); - const [arrivalSuggestions, setArrivalSuggestions] = useState([]); + // City autocomplete search + const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch(); + const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch(); - const handleDepartureSearch = useCallback((_event: AutoCompleteCompleteEvent) => { - setDepartureSuggestions([]); - }, []); + const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => { + void searchDeparture(event.query); + }, [searchDeparture]); - const handleArrivalSearch = useCallback((_event: AutoCompleteCompleteEvent) => { - setArrivalSuggestions([]); - }, []); + const handleArrivalSearch = useCallback((event: AutoCompleteCompleteEvent) => { + void searchArrival(event.query); + }, [searchArrival]); const handleSubmit = useCallback( (e: FormEvent) => { e.preventDefault(); - const dep = departureAirport.trim().toUpperCase(); - const arr = arrivalAirport.trim().toUpperCase(); + const dep = (typeof departureAirport === "string" + ? departureAirport.trim().toUpperCase() + : departureAirport.code); + const arr = (typeof arrivalAirport === "string" + ? arrivalAirport.trim().toUpperCase() + : arrivalAirport.code); if (!dep || !arr) return; if (!dateFrom || !dateTo) return; @@ -112,7 +117,8 @@ export const ScheduleStartPage: FC = () => { value={departureAirport} suggestions={departureSuggestions} completeMethod={handleDepartureSearch} - onChange={(e) => setDepartureAirport(e.value as string)} + field="name" + onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)} placeholder={t("SHARED.CITY_PLACEHOLDER")} className="input--filter" inputClassName="input--filter" @@ -127,7 +133,8 @@ export const ScheduleStartPage: FC = () => { value={arrivalAirport} suggestions={arrivalSuggestions} completeMethod={handleArrivalSearch} - onChange={(e) => setArrivalAirport(e.value as string)} + field="name" + onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)} placeholder={t("SHARED.CITY_PLACEHOLDER")} className="input--filter" inputClassName="input--filter" diff --git a/src/shared/hooks/useCitySearch.ts b/src/shared/hooks/useCitySearch.ts new file mode 100644 index 00000000..b85e97ec --- /dev/null +++ b/src/shared/hooks/useCitySearch.ts @@ -0,0 +1,122 @@ +/** + * City autocomplete search hook. + * + * Loads the cities dictionary from the API on first use, then searches + * the in-memory list by name prefix / inclusion — matching the Angular + * CitiesSearchService behavior. + * + * @module + */ + +import { useState, useRef, useCallback } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; + +// --------------------------------------------------------------------------- +// Types matching the Angular CityModel shape from the dictionary API +// --------------------------------------------------------------------------- + +interface DictionaryCity { + code: string; + title: Record; + country_code: string; + has_afl_flights?: boolean; + location?: { lat: number; lon: number }; +} + +export interface CitySuggestion { + /** IATA city code */ + code: string; + /** Localized display name */ + name: string; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +const MAX_ITEMS = 10; + +export function useCitySearch() { + const client = useApiClient(); + const [suggestions, setSuggestions] = useState([]); + const citiesRef = useRef(null); + const loadingRef = useRef | null>(null); + + const loadCities = useCallback(async (): Promise => { + if (citiesRef.current) return citiesRef.current; + if (loadingRef.current) return loadingRef.current; + + const promise = client + .get("dictionary/1/cities") + .then((raw) => { + const locale = client.locale; + const mapped: CitySuggestion[] = raw + .filter((c) => { + const title = c.title?.[locale] ?? c.title?.["ru"] ?? ""; + // Filter out entries with only latin characters (matches Angular filter) + return title && !/^[a-zA-Z.,:; ]+$/.test(title); + }) + .map((c) => ({ + code: c.code, + name: c.title?.[locale] ?? c.title?.["ru"] ?? c.code, + })); + citiesRef.current = mapped; + return mapped; + }); + + loadingRef.current = promise; + return promise; + }, [client]); + + const search = useCallback( + async (query: string) => { + if (!query || query.length === 0) { + setSuggestions([]); + return; + } + + const cities = await loadCities(); + const q = query.toLowerCase(); + + // If exactly 3 chars, try code match first + if (query.length === 3) { + const byCode = cities.filter( + (c) => c.code.toLowerCase() === q, + ); + if (byCode.length > 0) { + setSuggestions(byCode.slice(0, MAX_ITEMS)); + return; + } + } + + // Search by name: starts-with first, then includes + const startsWith = cities.filter((c) => + c.name.toLowerCase().startsWith(q), + ); + + if (startsWith.length >= MAX_ITEMS) { + setSuggestions(startsWith.slice(0, MAX_ITEMS)); + return; + } + + const includes = cities.filter((c) => + c.name.toLowerCase().includes(q), + ); + + // Deduplicate preserving startsWith order + const seen = new Set(startsWith.map((c) => c.code)); + const merged = [...startsWith]; + for (const c of includes) { + if (!seen.has(c.code)) { + seen.add(c.code); + merged.push(c); + } + } + + setSuggestions(merged.slice(0, MAX_ITEMS)); + }, + [loadCities], + ); + + return { suggestions, search }; +} diff --git a/tests/integration/online-board/start-page.test.tsx b/tests/integration/online-board/start-page.test.tsx index 4e346932..cbff200f 100644 --- a/tests/integration/online-board/start-page.test.tsx +++ b/tests/integration/online-board/start-page.test.tsx @@ -35,6 +35,10 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () => PopularRequestsPanel: () =>
Popular
, })); +vi.mock("@/shared/hooks/useCitySearch.js", () => ({ + useCitySearch: () => ({ suggestions: [], search: vi.fn() }), +})); + // --------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------