Fix all e2e failures, sass warnings, and HMR websocket errors
CI / ci (push) Failing after 38s
Deploy / build-and-deploy (push) Failing after 6s

- 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:
2026-04-16 00:23:10 +03:00
parent c6b865b324
commit d9bcccc1c5
6 changed files with 293 additions and 234 deletions
@@ -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>
);