Wire city autocomplete to dictionary API
CI / ci (push) Failing after 37s
Deploy / build-and-deploy (push) Failing after 6s

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.
This commit is contained in:
2026-04-15 21:32:39 +03:00
parent f61e050e8c
commit 5fc67f81bd
6 changed files with 201 additions and 48 deletions
@@ -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<FlightsMapFilterProps> = ({
onChange,
}) => {
const { t } = useTranslation();
const [departure, setDeparture] = useState(value.departure ?? "");
const [arrival, setArrival] = useState(value.arrival ?? "");
const [departure, setDeparture] = useState<CitySuggestion | string>(value.departure ?? "");
const [arrival, setArrival] = useState<CitySuggestion | string>(value.arrival ?? "");
// AutoComplete suggestions (populated by API in future; empty for now)
const [departureSuggestions, setDepartureSuggestions] = useState<string[]>([]);
const [arrivalSuggestions, setArrivalSuggestions] = useState<string[]>([]);
// 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<FlightsMapFilterProps> = ({
}, [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<FlightsMapFilterProps> = ({
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<FlightsMapFilterProps> = ({
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"
@@ -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<Date>(new Date());
// Route fields
const [departureAirport, setDepartureAirport] = useState("");
const [arrivalAirport, setArrivalAirport] = useState("");
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>("");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
const [routeDate, setRouteDate] = useState<Date>(new Date());
// AutoComplete suggestions (populated by API in future; empty for now)
const [departureSuggestions, setDepartureSuggestions] = useState<string[]>([]);
const [arrivalSuggestions, setArrivalSuggestions] = useState<string[]>([]);
// 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"
@@ -28,6 +28,10 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
PopularRequestsPanel: () => <div data-testid="popular-requests">Popular</div>,
}));
vi.mock("@/shared/hooks/useCitySearch.js", () => ({
useCitySearch: () => ({ suggestions: [], search: vi.fn() }),
}));
describe("OnlineBoardStartPage", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -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<CitySuggestion | string>("");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
const [dateFrom, setDateFrom] = useState<Date>(today);
const [dateTo, setDateTo] = useState<Date>(addDays(today, 7));
const [isRoundTrip, setIsRoundTrip] = useState(false);
const [returnDateFrom, setReturnDateFrom] = useState<Date>(addDays(today, 7));
const [returnDateTo, setReturnDateTo] = useState<Date>(addDays(today, 14));
// AutoComplete suggestions (populated by API in future; empty for now)
const [departureSuggestions, setDepartureSuggestions] = useState<string[]>([]);
const [arrivalSuggestions, setArrivalSuggestions] = useState<string[]>([]);
// 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"
+122
View File
@@ -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<string, string>;
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<CitySuggestion[]>([]);
const citiesRef = useRef<CitySuggestion[] | null>(null);
const loadingRef = useRef<Promise<CitySuggestion[]> | null>(null);
const loadCities = useCallback(async (): Promise<CitySuggestion[]> => {
if (citiesRef.current) return citiesRef.current;
if (loadingRef.current) return loadingRef.current;
const promise = client
.get<DictionaryCity[]>("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 };
}
@@ -35,6 +35,10 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
PopularRequestsPanel: () => <div data-testid="popular-requests">Popular</div>,
}));
vi.mock("@/shared/hooks/useCitySearch.js", () => ({
useCitySearch: () => ({ suggestions: [], search: vi.fn() }),
}));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------