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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user