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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user