Port Online Board start page UI to match Angular DOM and classes

Rewrite OnlineBoardStartPage to use PageLayout two-column structure,
add OnlineBoardFilter with PrimeNG-style accordion tabs, and render
the info tiles and popular requests section matching the Angular
template. Update tests for the new component structure.
This commit is contained in:
2026-04-15 18:58:51 +03:00
parent 74be36b705
commit 3c315d5114
6 changed files with 690 additions and 273 deletions
@@ -0,0 +1,170 @@
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
@use "../../../styles/colors" as colors;
@use "../../../styles/shadows" as shadows;
.online-board-filter {
section.frame {
background-color: colors.$blue-extra-light;
}
.p-accordion {
.p-accordion-tab {
.p-accordion-header {
border-radius: 0;
margin: 0;
a {
background-color: transparent;
border: none;
color: colors.$blue;
border-radius: 0;
padding: 0 vars.$space-l 0 vars.$space-xl;
height: vars.$button-height;
display: flex;
align-items: center;
font-weight: fonts.$font-bold;
cursor: pointer;
text-decoration: none;
.p-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
}
&.p-highlight {
a {
background-color: colors.$white;
border: none;
color: colors.$text-color;
}
border-bottom: none;
}
}
.p-accordion-content {
border-bottom: 1px solid colors.$border;
@include shadows.box-shadow-small;
padding: vars.$space-s vars.$space-xl vars.$space-xl vars.$space-xl;
}
&:first-child {
.p-accordion-header {
a {
border-radius: 3px 3px 0 0;
}
}
}
&:not(:last-child) {
.p-accordion-header {
border-bottom: 1px solid colors.$border;
}
}
&:last-child {
.p-accordion-content {
border-radius: 0 0 3px 3px;
border: none;
}
}
}
}
.label--filter {
display: block;
margin-right: vars.$space-xl;
@include fonts.font-overflow();
@include fonts.font-small(colors.$gray);
margin-bottom: vars.$label-margin-bottom;
}
.number-input-composite {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
.prefix {
display: flex;
align-items: center;
@include shadows.control-border-shadow();
height: vars.$standard-button-height;
padding: 0 vars.$space-l;
font-size: fonts.$font-size-l;
font-weight: fonts.$font-regular;
color: colors.$gray;
border-right: none;
border-radius: vars.$border-radius 0 0 vars.$border-radius;
}
}
.input {
&--filter {
display: flex;
align-items: center;
@include shadows.control-border-shadow();
height: vars.$standard-button-height;
padding-left: vars.$space-m !important;
font-size: fonts.$font-size-l;
font-weight: fonts.$font-regular;
color: colors.$text-color;
width: 100%;
transition-duration: 0.2s;
&:enabled:hover:not(.p-state-error) {
border-color: colors.$blue-light;
}
&:enabled:focus:not(.p-state-error) {
box-shadow: 0 0 0 0.2em colors.$focus-shadow;
border-color: colors.$blue-light;
}
}
&--flight-number {
display: block !important;
border-left: 1px dotted colors.$border-input;
border-radius: 0 vars.$border-radius vars.$border-radius 0;
}
&--calendar {
padding-right: 32px;
background-image: url('/assets/img/calendar.svg');
background-repeat: no-repeat;
background-size: 16px 16px;
background-position: right 8px center;
cursor: pointer;
}
}
.calendar {
margin-top: vars.$space-xl;
}
.search-button {
margin-top: vars.$space-xl;
width: 100%;
height: vars.$standard-button-height;
span {
font-weight: fonts.$font-bold;
font-size: fonts.$font-size-m;
}
}
.arrow-icon {
display: inline-block;
width: 12px;
height: 8px;
transition: transform 0.2s ease;
&--rotated {
transform: rotate(180deg);
}
}
}
@@ -0,0 +1,265 @@
/**
* 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.
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { buildOnlineBoardUrl } from "../url.js";
import "./OnlineBoardFilter.scss";
type FilterTab = "flight" | "route" | null;
function todayAsYyyymmdd(): string {
const now = new Date();
const y = now.getFullYear().toString();
const m = (now.getMonth() + 1).toString().padStart(2, "0");
const d = now.getDate().toString().padStart(2, "0");
return `${y}${m}${d}`;
}
function dateInputToYyyymmdd(value: string): string {
return value.replace(/-/g, "");
}
function yyyymmddToDateInput(value: string): string {
if (value.length !== 8) return "";
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
export const OnlineBoardFilter: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const [selectedTab, setSelectedTab] = useState<FilterTab>("flight");
// Flight number fields
const [flightNumber, setFlightNumber] = useState("");
const [flightDate, setFlightDate] = useState(
yyyymmddToDateInput(todayAsYyyymmdd()),
);
// Route fields
const [departureAirport, setDepartureAirport] = useState("");
const [arrivalAirport, setArrivalAirport] = useState("");
const [routeDate, setRouteDate] = useState(
yyyymmddToDateInput(todayAsYyyymmdd()),
);
const handleTabClick = useCallback(
(tab: FilterTab) => {
setSelectedTab(selectedTab === tab ? null : tab);
},
[selectedTab],
);
const handleFlightSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
const dateParam = dateInputToYyyymmdd(flightDate);
if (dateParam.length !== 8) return;
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}`);
},
[flightNumber, flightDate, navigate, lang],
);
const handleRouteSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
const dateParam = dateInputToYyyymmdd(routeDate);
if (dateParam.length !== 8) return;
if (!departureAirport.trim() || !arrivalAirport.trim()) return;
const url = buildOnlineBoardUrl({
type: "route",
departure: departureAirport.trim().toUpperCase(),
arrival: arrivalAirport.trim().toUpperCase(),
date: dateParam,
});
void navigate(`/${lang}/${url}`);
},
[departureAirport, arrivalAirport, routeDate, navigate, lang],
);
return (
<div className="online-board-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>
<input
type="date"
className="input--filter input--calendar"
value={flightDate}
onChange={(e) => setFlightDate(e.target.value)}
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>
)}
</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">
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<input
type="text"
className="input--filter"
placeholder={t("SHARED.CITY_PLACEHOLDER")}
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
data-testid="departure-airport-input"
/>
<div className="calendar">
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<input
type="text"
className="input--filter"
placeholder={t("SHARED.CITY_PLACEHOLDER")}
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
data-testid="arrival-airport-input"
/>
</div>
<div className="calendar">
<label className="label--filter">
{t("SHARED.DEPARTURE_DATE")}
</label>
<input
type="date"
className="input--filter input--calendar"
value={routeDate}
onChange={(e) => setRouteDate(e.target.value)}
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>
</div>
</section>
</div>
);
};
@@ -0,0 +1,110 @@
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
@use "../../../styles/colors" as colors;
@use "../../../styles/screen" as screen;
.online-board-start-page {
section.frame {
padding: 0;
h2 {
padding: 50px;
padding-bottom: 20px;
}
.titles-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0 50px;
padding-bottom: 50px;
.title {
width: 50%;
padding: 30px;
padding-right: 50px;
padding-left: 65px;
background-repeat: no-repeat;
background-position: left center;
a {
cursor: default;
font-size: fonts.$font-size-xl;
}
div {
color: colors.$gray;
padding-top: vars.$space-s;
}
&.title1 {
background-image: url('/assets/img/title-icon-1.svg');
}
&.title2 {
background-image: url('/assets/img/title-icon-2.svg');
}
&.title3 {
background-image: url('/assets/img/title-icon-3.svg');
}
&.title4 {
background-image: url('/assets/img/title-icon-4.svg');
}
}
}
}
@media (max-width: vars.$media-breakpoint-mobile) {
section.frame h2 {
padding: 20px;
font-size: 20px;
line-height: 28px;
padding-bottom: 0px;
padding-top: 30px;
}
.titles-container {
padding: 20px !important;
padding-top: 0 !important;
div.title {
width: 100% !important;
padding: 20px !important;
padding-left: 50px !important;
background-size: 35px auto !important;
a {
font-size: 16px;
}
}
}
}
.page-title {
width: auto;
display: inline-block;
max-width: 100%;
white-space: normal !important;
}
h1.text--white {
@include fonts.font-overflow();
@include screen.smTablet {
font-size: fonts.$font-size-xxxl--tablet;
margin-bottom: vars.$space-m + vars.$space-s;
overflow: visible;
white-space: normal;
}
@include screen.mobile {
font-size: fonts.$font-size-xxxl--mobile;
margin-bottom: vars.$space-m + vars.$space-s;
margin-top: vars.$space-m;
overflow: visible;
white-space: normal;
}
}
}
@@ -1,20 +1,31 @@
/**
* Tests for OnlineBoardStartPage component.
*
* Verifies form rendering with different search modes and submit behavior.
* Verifies page layout rendering with filter and info sections.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { OnlineBoardStartPage } from "./OnlineBoardStartPage.js";
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => mockNavigate,
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () => ({
PopularRequestsPanel: () => <div data-testid="popular-requests">Popular</div>,
}));
describe("OnlineBoardStartPage", () => {
@@ -22,63 +33,44 @@ describe("OnlineBoardStartPage", () => {
vi.clearAllMocks();
});
it("renders start page with search form", () => {
it("renders start page with page layout structure", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("online-board-start")).toBeTruthy();
expect(screen.getByTestId("search-form")).toBeTruthy();
expect(screen.getByText("Online Board")).toBeTruthy();
});
it("renders search type radio buttons", () => {
it("renders the page title as h1", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByLabelText("Flight")).toBeTruthy();
expect(screen.getByLabelText("Departure")).toBeTruthy();
expect(screen.getByLabelText("Arrival")).toBeTruthy();
expect(screen.getByLabelText("Route")).toBeTruthy();
const heading = screen.getAllByText("BOARD.TITLE");
const h1 = heading.find((el) => el.tagName === "H1");
expect(h1).toBeTruthy();
});
it("shows flight number input by default (flight mode)", () => {
it("renders the info section heading", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("flight-number-input")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START")).toBeTruthy();
});
it("switches to departure mode and shows departure input", () => {
it("renders 4 info tiles", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByLabelText("Departure"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE1")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE2")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE3")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE4")).toBeTruthy();
});
it("switches to route mode and shows both airport inputs", () => {
it("renders the filter component", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByLabelText("Route"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
expect(screen.getByTestId("flight-filter")).toBeTruthy();
});
it("submits flight search and navigates", () => {
it("renders page tabs", () => {
render(<OnlineBoardStartPage />);
const flightInput = screen.getByTestId("flight-number-input") as HTMLInputElement;
fireEvent.change(flightInput, { target: { value: "SU100" } });
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(1);
const navigatedUrl = mockNavigate.mock.calls[0]?.[0] as string;
expect(navigatedUrl).toContain("/ru/onlineboard/flight/SU");
expect(screen.getByTestId("onlineboard-tab")).toBeTruthy();
expect(screen.getByTestId("schedule-tab")).toBeTruthy();
});
it("does not submit when flight number is empty", () => {
it("renders the popular requests panel", () => {
render(<OnlineBoardStartPage />);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).not.toHaveBeenCalled();
});
it("submits departure search and navigates", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByLabelText("Departure"));
const input = screen.getByTestId("departure-airport-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "SVO" } });
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(1);
const navigatedUrl = mockNavigate.mock.calls[0]?.[0] as string;
expect(navigatedUrl).toContain("/ru/onlineboard/departure/SVO");
expect(screen.getByTestId("popular-requests")).toBeTruthy();
});
});
@@ -1,211 +1,85 @@
/**
* Online Board start page — search form with tabs for different search modes.
* Online Board start page matching the Angular `online-board-start-page`
* component DOM structure and CSS class names.
*
* No API calls on load. Pure form that navigates to the appropriate
* search route on submit.
* Uses PageLayout for the two-column layout, PageTabs in the header-left,
* OnlineBoardFilter in content-left, and the info section + popular
* requests in the main content area.
*
* No API calls on load. Pure presentation that navigates to search
* routes via the filter component.
*
* @module
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { buildOnlineBoardUrl } from "../url.js";
import type { FlightRequestType } from "../types.js";
/**
* Format today's date as yyyyMMdd for URL params.
*/
function todayAsYyyymmdd(): string {
const now = new Date();
const y = now.getFullYear().toString();
const m = (now.getMonth() + 1).toString().padStart(2, "0");
const d = now.getDate().toString().padStart(2, "0");
return `${y}${m}${d}`;
}
/**
* Convert a date input value (yyyy-MM-dd) to yyyyMMdd format.
*/
function dateInputToYyyymmdd(value: string): string {
return value.replace(/-/g, "");
}
/**
* Convert yyyyMMdd to yyyy-MM-dd for date input value.
*/
function yyyymmddToDateInput(value: string): string {
if (value.length !== 8) return "";
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
import { type FC, useCallback } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import "./OnlineBoardStartPage.scss";
export const OnlineBoardStartPage: FC = () => {
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const { t } = useTranslation();
const [searchType, setSearchType] = useState<FlightRequestType>("flight");
const [flightNumber, setFlightNumber] = useState("");
const [departureAirport, setDepartureAirport] = useState("");
const [arrivalAirport, setArrivalAirport] = useState("");
const [date, setDate] = useState(yyyymmddToDateInput(todayAsYyyymmdd()));
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
const dateParam = dateInputToYyyymmdd(date);
if (dateParam.length !== 8) return;
let url: string;
switch (searchType) {
case "flight": {
if (!flightNumber.trim()) return;
// Extract carrier (first 2 chars) and number (rest)
const cleaned = flightNumber.trim().replace(/\s+/g, "");
const carrier = cleaned.slice(0, 2).toUpperCase();
const num = cleaned.slice(2);
if (!carrier || !num) return;
url = buildOnlineBoardUrl({
type: "flight",
carrier,
flightNumber: num,
date: dateParam,
});
break;
}
case "departure": {
if (!departureAirport.trim()) return;
url = buildOnlineBoardUrl({
type: "departure",
station: departureAirport.trim().toUpperCase(),
date: dateParam,
});
break;
}
case "arrival": {
if (!arrivalAirport.trim()) return;
url = buildOnlineBoardUrl({
type: "arrival",
station: arrivalAirport.trim().toUpperCase(),
date: dateParam,
});
break;
}
case "route": {
if (!departureAirport.trim() || !arrivalAirport.trim()) return;
url = buildOnlineBoardUrl({
type: "route",
departure: departureAirport.trim().toUpperCase(),
arrival: arrivalAirport.trim().toUpperCase(),
date: dateParam,
});
break;
}
}
void navigate(`/${lang}/${url}`);
},
[searchType, flightNumber, departureAirport, arrivalAirport, date, navigate, lang],
);
const handlePopularRequestClick = useCallback((_request: PopularRequest) => {
// Navigation is handled by PopularRequestItem internally;
// this callback is available for analytics or custom behavior.
}, []);
return (
<div className="online-board-start" data-testid="online-board-start">
<h1 className="online-board-start__title">Online Board</h1>
<form
className="online-board-start__form"
data-testid="search-form"
onSubmit={handleSubmit}
<div className="online-board-start-page" data-testid="online-board-start">
<PageLayout
headerLeft={
<PageTabs viewType="onlineboard" />
}
title={
<h1 className="text--white page-title">
{t("BOARD.TITLE")}
</h1>
}
contentLeft={
<OnlineBoardFilter />
}
>
{/* Search mode tabs */}
<fieldset className="online-board-start__tabs">
<legend>Search type</legend>
{(["flight", "departure", "arrival", "route"] as const).map((type) => (
<label key={type} className="online-board-start__tab">
<input
type="radio"
name="searchType"
value={type}
checked={searchType === type}
onChange={() => setSearchType(type)}
/>
{type.charAt(0).toUpperCase() + type.slice(1)}
</label>
))}
</fieldset>
<section className="frame">
<h2>{t("BOARD.BOARD-START")}</h2>
{/* Flight number input (flight mode) */}
{searchType === "flight" && (
<div className="online-board-start__field">
<label htmlFor="flight-number">Flight number</label>
<input
id="flight-number"
type="text"
placeholder="e.g. SU100"
value={flightNumber}
onChange={(e) => setFlightNumber(e.target.value)}
data-testid="flight-number-input"
/>
<div className="titles-container">
<div className="title title1">
<a>{t("BOARD.BOARD-START-TITLE1")}</a>
<div>
{t("BOARD.BOARD-START-TITLE1-DESCRIPTION")}
</div>
</div>
<div className="title title2">
<a>{t("BOARD.BOARD-START-TITLE2")}</a>
<div>
{t("BOARD.BOARD-START-TITLE2-DESCRIPTION")}
</div>
</div>
<div className="title title3">
<a>{t("BOARD.BOARD-START-TITLE3")}</a>
<div>
{t("BOARD.BOARD-START-TITLE3-DESCRIPTION")}
</div>
</div>
<div className="title title4">
<a>{t("BOARD.BOARD-START-TITLE4")}</a>
<div>
{t("BOARD.BOARD-START-TITLE4-DESCRIPTION")}
</div>
</div>
</div>
)}
{/* Departure airport (departure, route modes) */}
{(searchType === "departure" || searchType === "route") && (
<div className="online-board-start__field">
<label htmlFor="departure-airport">Departure airport</label>
<input
id="departure-airport"
type="text"
placeholder="e.g. SVO"
maxLength={3}
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
data-testid="departure-airport-input"
/>
</div>
)}
{/* Arrival airport (arrival, route modes) */}
{(searchType === "arrival" || searchType === "route") && (
<div className="online-board-start__field">
<label htmlFor="arrival-airport">Arrival airport</label>
<input
id="arrival-airport"
type="text"
placeholder="e.g. JFK"
maxLength={3}
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
data-testid="arrival-airport-input"
/>
</div>
)}
{/* Date input */}
<div className="online-board-start__field">
<label htmlFor="search-date">Date</label>
<input
id="search-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
data-testid="date-input"
/>
</div>
{/* Submit */}
<button
type="submit"
className="online-board-start__submit"
data-testid="search-submit"
>
Search
</button>
</form>
<PopularRequestsPanel onRequestClick={handlePopularRequestClick} />
</section>
</PageLayout>
</div>
);
};
@@ -1,8 +1,8 @@
/**
* Integration tests for the Online Board start page.
*
* Verifies the search form renders with all mode tabs and
* correct fields per search type.
* Verifies the page layout renders with filter accordion,
* info tiles, and page tabs matching Angular structure.
*
* @vitest-environment jsdom
*/
@@ -20,6 +20,19 @@ const navigateSpy = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => navigateSpy,
useParams: () => ({ lang: "ru" }),
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () => ({
PopularRequestsPanel: () => <div data-testid="popular-requests">Popular</div>,
}));
// ---------------------------------------------------------------------------
@@ -31,74 +44,67 @@ describe("Start page integration", () => {
vi.clearAllMocks();
});
it("renders the search form with data-testid", () => {
it("renders the page layout with data-testid", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("search-form")).toBeTruthy();
expect(screen.getByTestId("online-board-start")).toBeTruthy();
});
it("renders all 4 search mode tabs", () => {
it("renders page tabs for onlineboard and schedule", () => {
render(<OnlineBoardStartPage />);
const radios = screen.getAllByRole("radio");
expect(radios).toHaveLength(4);
const labels = radios.map((r) => (r as HTMLInputElement).value);
expect(labels).toEqual(["flight", "departure", "arrival", "route"]);
expect(screen.getByTestId("onlineboard-tab")).toBeTruthy();
expect(screen.getByTestId("schedule-tab")).toBeTruthy();
});
it("defaults to flight search mode", () => {
it("renders the filter accordion with flight and route tabs", () => {
render(<OnlineBoardStartPage />);
const flightRadio = screen.getByDisplayValue("flight") as HTMLInputElement;
expect(flightRadio.checked).toBe(true);
expect(screen.getByTestId("flight-filter")).toBeTruthy();
expect(screen.getByTestId("route-filter")).toBeTruthy();
});
it("shows flight number input in flight mode", () => {
it("shows flight number input in the flight filter by default", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("flight-number-input")).toBeTruthy();
});
it("shows departure airport input in departure mode", () => {
it("renders all 4 info tiles", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("departure"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE1")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE2")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE3")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START-TITLE4")).toBeTruthy();
});
it("shows arrival airport input in arrival mode", () => {
it("renders the info section heading", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("arrival"));
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
expect(screen.getByText("BOARD.BOARD-START")).toBeTruthy();
});
it("shows both departure and arrival inputs in route mode", () => {
it("renders the popular requests panel", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("route"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
expect(screen.getByTestId("popular-requests")).toBeTruthy();
});
it("navigates to correct URL on flight search submit", () => {
render(<OnlineBoardStartPage />);
const input = screen.getByTestId("flight-number-input");
// Filter has SU prefix built in, so user enters just the number
fireEvent.change(input, { target: { value: "SU100" } });
const form = screen.getByTestId("search-form");
const form = screen.getByTestId("flight-search-form");
fireEvent.submit(form);
expect(navigateSpy).toHaveBeenCalledTimes(1);
const url = navigateSpy.mock.calls[0]?.[0] as string;
expect(url).toMatch(/^\/ru\/onlineboard\/flight\/SU0100-\d{8}$/);
expect(url).toMatch(/^\/ru\/onlineboard\/flight\/SU/);
});
it("navigates to correct URL on departure search submit", () => {
it("switches to route filter and shows route fields", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("departure"));
const input = screen.getByTestId("departure-airport-input");
fireEvent.change(input, { target: { value: "SVO" } });
const form = screen.getByTestId("search-form");
fireEvent.submit(form);
expect(navigateSpy).toHaveBeenCalledTimes(1);
const url = navigateSpy.mock.calls[0]?.[0] as string;
expect(url).toMatch(/^\/ru\/onlineboard\/departure\/SVO-\d{8}$/);
// Click route tab header
const routeHeader = screen.getByTestId("route-filter").querySelector(".p-accordion-header a");
expect(routeHeader).toBeTruthy();
if (routeHeader) fireEvent.click(routeHeader);
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
});
});