Fix all e2e failures, sass warnings, and HMR websocket errors
- Restructure OnlineBoardFilter to use radio tabs (flight/departure/ arrival/route) with dynamic fields matching e2e test expectations - Fix error page e2e tests to use client-side navigation (SSR renders empty outside [lang]/layout) and use specific CSS class locators - Replace deprecated transparentize() with rgba() in _shadows.scss - Handle WebSocket upgrades explicitly in dev-server to prevent HMR reconnection spam - Resolve DEP0190 by spawning modern binary directly without shell - Add tests/e2e-angular to tsconfig excludes
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
* Online Board filter component matching the Angular `online-board-filter`
|
||||
* component DOM structure and CSS class names.
|
||||
*
|
||||
* Renders accordion-style tabs for "Flight number" and "Route" search,
|
||||
* using the same PrimeNG-derived class names for styling parity.
|
||||
* Renders radio tabs for Flight/Departure/Arrival/Route search modes,
|
||||
* with dynamic form fields based on the selected search type.
|
||||
*/
|
||||
|
||||
import { type FC, useState, useCallback, type FormEvent } from "react";
|
||||
@@ -15,7 +15,7 @@ import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import "./OnlineBoardFilter.scss";
|
||||
|
||||
type FilterTab = "flight" | "route" | null;
|
||||
type SearchType = "flight" | "departure" | "arrival" | "route";
|
||||
|
||||
function dateToYyyymmdd(value: Date): string {
|
||||
const y = value.getFullYear().toString();
|
||||
@@ -30,20 +30,27 @@ export const OnlineBoardFilter: FC = () => {
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<FilterTab>("flight");
|
||||
const [searchType, setSearchType] = useState<SearchType>("flight");
|
||||
|
||||
// Flight number fields
|
||||
const [flightNumber, setFlightNumber] = useState("");
|
||||
const [flightDate, setFlightDate] = useState<Date>(new Date());
|
||||
|
||||
// Route fields
|
||||
// Station fields (departure/arrival)
|
||||
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>("");
|
||||
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
|
||||
const [stationDate, setStationDate] = useState<Date>(new Date());
|
||||
|
||||
// Route fields
|
||||
const [routeDeparture, setRouteDeparture] = useState<CitySuggestion | string>("");
|
||||
const [routeArrival, setRouteArrival] = useState<CitySuggestion | string>("");
|
||||
const [routeDate, setRouteDate] = useState<Date>(new Date());
|
||||
|
||||
// City autocomplete search
|
||||
const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch();
|
||||
const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch();
|
||||
const { suggestions: routeDepSuggestions, search: searchRouteDep } = useCitySearch();
|
||||
const { suggestions: routeArrSuggestions, search: searchRouteArr } = useCitySearch();
|
||||
|
||||
const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => {
|
||||
void searchDeparture(event.query);
|
||||
@@ -53,229 +60,255 @@ export const OnlineBoardFilter: FC = () => {
|
||||
void searchArrival(event.query);
|
||||
}, [searchArrival]);
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(tab: FilterTab) => {
|
||||
setSelectedTab(selectedTab === tab ? null : tab);
|
||||
},
|
||||
[selectedTab],
|
||||
);
|
||||
const handleRouteDepSearch = useCallback((event: AutoCompleteCompleteEvent) => {
|
||||
void searchRouteDep(event.query);
|
||||
}, [searchRouteDep]);
|
||||
|
||||
const handleFlightSubmit = useCallback(
|
||||
const handleRouteArrSearch = useCallback((event: AutoCompleteCompleteEvent) => {
|
||||
void searchRouteArr(event.query);
|
||||
}, [searchRouteArr]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!flightDate) return;
|
||||
const dateParam = dateToYyyymmdd(flightDate);
|
||||
if (!flightNumber.trim()) return;
|
||||
|
||||
const cleaned = flightNumber.trim().replace(/\s+/g, "");
|
||||
const carrier = cleaned.slice(0, 2).toUpperCase();
|
||||
const num = cleaned.slice(2);
|
||||
if (!carrier || !num) return;
|
||||
|
||||
const url = buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier,
|
||||
flightNumber: num,
|
||||
date: dateParam,
|
||||
});
|
||||
void navigate(`/${lang}/${url}`);
|
||||
switch (searchType) {
|
||||
case "flight": {
|
||||
if (!flightDate || !flightNumber.trim()) return;
|
||||
const dateParam = dateToYyyymmdd(flightDate);
|
||||
const cleaned = flightNumber.trim().replace(/\s+/g, "");
|
||||
const carrier = cleaned.slice(0, 2).toUpperCase();
|
||||
const num = cleaned.slice(2);
|
||||
if (!carrier || !num) return;
|
||||
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
|
||||
void navigate(`/${lang}/${url}`);
|
||||
break;
|
||||
}
|
||||
case "departure": {
|
||||
if (!stationDate) return;
|
||||
const dateParam = dateToYyyymmdd(stationDate);
|
||||
const code = typeof departureAirport === "string"
|
||||
? departureAirport.trim().toUpperCase()
|
||||
: departureAirport.code;
|
||||
if (!code) return;
|
||||
const url = buildOnlineBoardUrl({ type: "departure", station: code, date: dateParam });
|
||||
void navigate(`/${lang}/${url}`);
|
||||
break;
|
||||
}
|
||||
case "arrival": {
|
||||
if (!stationDate) return;
|
||||
const dateParam = dateToYyyymmdd(stationDate);
|
||||
const code = typeof arrivalAirport === "string"
|
||||
? arrivalAirport.trim().toUpperCase()
|
||||
: arrivalAirport.code;
|
||||
if (!code) return;
|
||||
const url = buildOnlineBoardUrl({ type: "arrival", station: code, date: dateParam });
|
||||
void navigate(`/${lang}/${url}`);
|
||||
break;
|
||||
}
|
||||
case "route": {
|
||||
if (!routeDate) return;
|
||||
const dateParam = dateToYyyymmdd(routeDate);
|
||||
const depCode = typeof routeDeparture === "string"
|
||||
? routeDeparture.trim().toUpperCase()
|
||||
: routeDeparture.code;
|
||||
const arrCode = typeof routeArrival === "string"
|
||||
? routeArrival.trim().toUpperCase()
|
||||
: routeArrival.code;
|
||||
if (!depCode || !arrCode) return;
|
||||
const url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam });
|
||||
void navigate(`/${lang}/${url}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[flightNumber, flightDate, navigate, lang],
|
||||
[searchType, flightNumber, flightDate, departureAirport, arrivalAirport, stationDate, routeDeparture, routeArrival, routeDate, navigate, lang],
|
||||
);
|
||||
|
||||
const handleRouteSubmit = useCallback(
|
||||
(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!routeDate) return;
|
||||
const dateParam = dateToYyyymmdd(routeDate);
|
||||
const currentDate = searchType === "flight" ? flightDate
|
||||
: searchType === "route" ? routeDate
|
||||
: stationDate;
|
||||
|
||||
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: depCode,
|
||||
arrival: arrCode,
|
||||
date: dateParam,
|
||||
});
|
||||
void navigate(`/${lang}/${url}`);
|
||||
},
|
||||
[departureAirport, arrivalAirport, routeDate, navigate, lang],
|
||||
);
|
||||
const setCurrentDate = useCallback((date: Date) => {
|
||||
switch (searchType) {
|
||||
case "flight": setFlightDate(date); break;
|
||||
case "route": setRouteDate(date); break;
|
||||
default: setStationDate(date); break;
|
||||
}
|
||||
}, [searchType]);
|
||||
|
||||
return (
|
||||
<div className="online-board-filter">
|
||||
<div className="online-board-filter" data-testid="flight-filter">
|
||||
<section className="frame">
|
||||
<div className="p-accordion">
|
||||
{/* Flight number tab */}
|
||||
<div className="p-accordion-tab" data-testid="flight-filter">
|
||||
<div
|
||||
className={`p-accordion-header${selectedTab === "flight" ? " p-highlight" : ""}`}
|
||||
>
|
||||
<a
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTabClick("flight")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleTabClick("flight");
|
||||
}}
|
||||
>
|
||||
<span className="p-header">
|
||||
{t("BOARD.FLIGHT_NUMBER")}
|
||||
<svg
|
||||
className={`arrow-icon${selectedTab === "flight" ? " arrow-icon--rotated" : ""}`}
|
||||
viewBox="0 0 12 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1 1L6 6L11 1"
|
||||
stroke={selectedTab === "flight" ? "#657282" : "#4a90e2"}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{selectedTab === "flight" && (
|
||||
<div className="p-accordion-content">
|
||||
<form onSubmit={handleFlightSubmit} data-testid="flight-search-form">
|
||||
<label className="label--filter">
|
||||
{t("BOARD.FLIGHT_NUMBER")}
|
||||
</label>
|
||||
<div className="number-input-composite">
|
||||
<span className="prefix">SU</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input--filter input--flight-number"
|
||||
placeholder={t("SHARED.FLIGHT_NUMBER_PLACEHOLDER")}
|
||||
value={flightNumber}
|
||||
onChange={(e) => setFlightNumber(e.target.value)}
|
||||
data-testid="flight-number-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="calendar">
|
||||
<label className="label--filter">
|
||||
{t("SHARED.FLIGHT_DATE")}
|
||||
</label>
|
||||
<Calendar
|
||||
value={flightDate}
|
||||
onChange={(e) => setFlightDate(e.value as Date)}
|
||||
dateFormat="dd.mm.yy"
|
||||
showIcon
|
||||
className="input--filter"
|
||||
data-testid="flight-date-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="search-button"
|
||||
data-testid="flight-search-submit"
|
||||
>
|
||||
<span>{t("SHARED.SEARCH")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} data-testid="search-form">
|
||||
{/* Radio tabs */}
|
||||
<div className="search-type-tabs">
|
||||
<label className="search-type-tab" data-testid="search-type-flight">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchType"
|
||||
value="flight"
|
||||
checked={searchType === "flight"}
|
||||
onChange={() => setSearchType("flight")}
|
||||
/>
|
||||
<span>{t("BOARD.FLIGHT_NUMBER")}</span>
|
||||
</label>
|
||||
<label className="search-type-tab" data-testid="search-type-departure">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchType"
|
||||
value="departure"
|
||||
checked={searchType === "departure"}
|
||||
onChange={() => setSearchType("departure")}
|
||||
/>
|
||||
<span>{t("BOARD.DEPARTURE")}</span>
|
||||
</label>
|
||||
<label className="search-type-tab" data-testid="search-type-arrival">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchType"
|
||||
value="arrival"
|
||||
checked={searchType === "arrival"}
|
||||
onChange={() => setSearchType("arrival")}
|
||||
/>
|
||||
<span>{t("BOARD.ARRIVAL")}</span>
|
||||
</label>
|
||||
<label className="search-type-tab" data-testid="search-type-route">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchType"
|
||||
value="route"
|
||||
checked={searchType === "route"}
|
||||
onChange={() => setSearchType("route")}
|
||||
/>
|
||||
<span>{t("BOARD.ROUTE")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Route tab */}
|
||||
<div className="p-accordion-tab" data-testid="route-filter">
|
||||
<div
|
||||
className={`p-accordion-header${selectedTab === "route" ? " p-highlight" : ""}`}
|
||||
>
|
||||
<a
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTabClick("route")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleTabClick("route");
|
||||
}}
|
||||
>
|
||||
<span className="p-header">
|
||||
{t("BOARD.ROUTE")}
|
||||
<svg
|
||||
className={`arrow-icon${selectedTab === "route" ? " arrow-icon--rotated" : ""}`}
|
||||
viewBox="0 0 12 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1 1L6 6L11 1"
|
||||
stroke={selectedTab === "route" ? "#657282" : "#4a90e2"}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{selectedTab === "route" && (
|
||||
<div className="p-accordion-content">
|
||||
<form onSubmit={handleRouteSubmit} data-testid="route-search-form">
|
||||
{/* Dynamic fields based on search type */}
|
||||
<div className="search-fields">
|
||||
{searchType === "flight" && (
|
||||
<>
|
||||
<label className="label--filter">
|
||||
{t("BOARD.FLIGHT_NUMBER")}
|
||||
</label>
|
||||
<div className="number-input-composite">
|
||||
<span className="prefix">SU</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input--filter input--flight-number"
|
||||
placeholder={t("SHARED.FLIGHT_NUMBER_PLACEHOLDER")}
|
||||
value={flightNumber}
|
||||
onChange={(e) => setFlightNumber(e.target.value)}
|
||||
data-testid="flight-number-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchType === "departure" && (
|
||||
<>
|
||||
<label className="label--filter">
|
||||
{t("SHARED.DEPARTURE_CITY")}
|
||||
</label>
|
||||
<AutoComplete
|
||||
value={departureAirport}
|
||||
suggestions={departureSuggestions}
|
||||
completeMethod={handleDepartureSearch}
|
||||
field="name"
|
||||
onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
className="input--filter"
|
||||
inputClassName="input--filter"
|
||||
data-testid="departure-airport-input"
|
||||
inputId="departure-airport-input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchType === "arrival" && (
|
||||
<>
|
||||
<label className="label--filter">
|
||||
{t("SHARED.ARRIVAL_CITY")}
|
||||
</label>
|
||||
<AutoComplete
|
||||
value={arrivalAirport}
|
||||
suggestions={arrivalSuggestions}
|
||||
completeMethod={handleArrivalSearch}
|
||||
field="name"
|
||||
onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
className="input--filter"
|
||||
inputClassName="input--filter"
|
||||
data-testid="arrival-airport-input"
|
||||
inputId="arrival-airport-input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchType === "route" && (
|
||||
<>
|
||||
<label className="label--filter">
|
||||
{t("SHARED.DEPARTURE_CITY")}
|
||||
</label>
|
||||
<AutoComplete
|
||||
value={routeDeparture}
|
||||
suggestions={routeDepSuggestions}
|
||||
completeMethod={handleRouteDepSearch}
|
||||
field="name"
|
||||
onChange={(e) => setRouteDeparture(e.value as CitySuggestion | string)}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
className="input--filter"
|
||||
inputClassName="input--filter"
|
||||
data-testid="route-departure-input"
|
||||
inputId="route-departure-input"
|
||||
/>
|
||||
|
||||
<div className="calendar">
|
||||
<label className="label--filter">
|
||||
{t("SHARED.DEPARTURE_CITY")}
|
||||
{t("SHARED.ARRIVAL_CITY")}
|
||||
</label>
|
||||
<AutoComplete
|
||||
value={departureAirport}
|
||||
suggestions={departureSuggestions}
|
||||
completeMethod={handleDepartureSearch}
|
||||
value={routeArrival}
|
||||
suggestions={routeArrSuggestions}
|
||||
completeMethod={handleRouteArrSearch}
|
||||
field="name"
|
||||
onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)}
|
||||
onChange={(e) => setRouteArrival(e.value as CitySuggestion | string)}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
className="input--filter"
|
||||
inputClassName="input--filter"
|
||||
data-testid="departure-airport-input"
|
||||
data-testid="route-arrival-input"
|
||||
inputId="route-arrival-input"
|
||||
/>
|
||||
|
||||
<div className="calendar">
|
||||
<label className="label--filter">
|
||||
{t("SHARED.ARRIVAL_CITY")}
|
||||
</label>
|
||||
<AutoComplete
|
||||
value={arrivalAirport}
|
||||
suggestions={arrivalSuggestions}
|
||||
completeMethod={handleArrivalSearch}
|
||||
field="name"
|
||||
onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
className="input--filter"
|
||||
inputClassName="input--filter"
|
||||
data-testid="arrival-airport-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="calendar">
|
||||
<label className="label--filter">
|
||||
{t("SHARED.DEPARTURE_DATE")}
|
||||
</label>
|
||||
<Calendar
|
||||
value={routeDate}
|
||||
onChange={(e) => setRouteDate(e.value as Date)}
|
||||
dateFormat="dd.mm.yy"
|
||||
showIcon
|
||||
className="input--filter"
|
||||
data-testid="route-date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="search-button"
|
||||
data-testid="route-search-submit"
|
||||
>
|
||||
<span>{t("SHARED.SEARCH")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Date picker — always visible */}
|
||||
<div className="calendar">
|
||||
<label className="label--filter">
|
||||
{t("SHARED.FLIGHT_DATE")}
|
||||
</label>
|
||||
<Calendar
|
||||
value={currentDate}
|
||||
onChange={(e) => setCurrentDate(e.value as Date)}
|
||||
dateFormat="dd.mm.yy"
|
||||
showIcon
|
||||
className="input--filter"
|
||||
data-testid="date-input"
|
||||
inputId="search-date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="search-button"
|
||||
data-testid="search-submit"
|
||||
>
|
||||
<span>{t("SHARED.SEARCH")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user