Add feature components for online board pages
OnlineBoardSearchPage (shared by all 4 search routes), OnlineBoardStartPage (search form landing), and OnlineBoardDetailsPage (flight detail view with legs). All wired to existing hooks from 2C/2D. 21 tests passing.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Tests for OnlineBoardDetailsPage component.
|
||||
*
|
||||
* Verifies rendering in loading, error, not-found, and success states.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { OnlineBoardDetailsPage } from "./OnlineBoardDetailsPage.js";
|
||||
import type { IParsedFlightId, IDirectFlight } from "../types.js";
|
||||
|
||||
const mockFlightId: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
const mockFlight: IDirectFlight = {
|
||||
id: "SU100-20250115",
|
||||
flightId: { carrier: "SU", flightNumber: "100", suffix: "", date: "20250115" },
|
||||
routeType: "Direct",
|
||||
status: "Scheduled",
|
||||
flyingTime: "10:30",
|
||||
operatingBy: {},
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "10:30",
|
||||
dayChange: 0,
|
||||
updated: "2025-01-15T00:00:00Z",
|
||||
operatingBy: {},
|
||||
equipment: { name: "Boeing 777-300ER", code: "77W" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: "Sheremetyevo",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "open",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "10:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "07:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: "John F. Kennedy",
|
||||
airportCode: "JFK",
|
||||
city: "New York",
|
||||
cityCode: "NYC",
|
||||
countryCode: "US",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "14:30",
|
||||
localTime: "14:30",
|
||||
tzOffset: -5,
|
||||
utc: "19:30",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mutable state for test control
|
||||
let mockState = {
|
||||
flight: mockFlight as IDirectFlight | null,
|
||||
loading: false,
|
||||
error: null as Error | null,
|
||||
};
|
||||
|
||||
vi.mock("../hooks/useFlightDetails.js", () => ({
|
||||
useFlightDetails: () => mockState,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useLiveFlightDetails.js", () => ({
|
||||
useLiveFlightDetails: (_id: unknown, initialFlight: unknown) => ({
|
||||
flight: initialFlight,
|
||||
connectionStatus: "idle" as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("OnlineBoardDetailsPage", () => {
|
||||
beforeEach(() => {
|
||||
mockState = {
|
||||
flight: mockFlight,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
it("renders flight details", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.getByTestId("flight-details")).toBeTruthy();
|
||||
// "SU 100" appears in both the header and FlightCard
|
||||
expect(screen.getAllByText("SU 100").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders loading skeleton", () => {
|
||||
mockState = { flight: null, loading: true, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.queryByTestId("flight-details")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error state", () => {
|
||||
mockState = { flight: null, loading: false, error: new Error("fail") };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.getByTestId("flight-details-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders not-found state", () => {
|
||||
mockState = { flight: null, loading: false, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders flight legs", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.getByTestId("flight-legs")).toBeTruthy();
|
||||
expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays departure and arrival stations", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
// SVO and JFK appear in both FlightCard and FlightLegs sections
|
||||
expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("displays aircraft info", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.getByText("Aircraft: Boeing 777-300ER (77W)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays flying time", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
|
||||
expect(screen.getByTestId("flying-time")).toBeTruthy();
|
||||
expect(screen.getByText("Total flying time: 10:30")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Online Board flight details page component.
|
||||
*
|
||||
* Receives parsed flight ID, fetches flight details via useFlightDetails,
|
||||
* wires live updates via useLiveFlightDetails, renders detailed flight info.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import { FlightCard } from "@/ui/flights/FlightCard.js";
|
||||
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
|
||||
import { useFlightDetails } from "../hooks/useFlightDetails.js";
|
||||
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
|
||||
import type { IParsedFlightId, IFlightLeg } from "../types.js";
|
||||
|
||||
export interface OnlineBoardDetailsPageProps {
|
||||
/** Parsed flight identifier from the URL */
|
||||
flightId: IParsedFlightId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all legs of a flight with departure/arrival station details.
|
||||
*/
|
||||
function FlightLegs({ legs }: { legs: IFlightLeg[] }): JSX.Element {
|
||||
return (
|
||||
<div className="flight-details__legs" data-testid="flight-legs">
|
||||
{legs.map((leg) => (
|
||||
<div key={leg.index} className="flight-details__leg" data-testid={`flight-leg-${leg.index}`}>
|
||||
<div className="flight-details__leg-header">
|
||||
<span className="flight-details__leg-index">Leg {leg.index + 1}</span>
|
||||
<span className="flight-details__leg-status">{leg.status}</span>
|
||||
</div>
|
||||
|
||||
<div className="flight-details__leg-stations">
|
||||
<div className="flight-details__leg-departure">
|
||||
<span className="flight-details__station-code">
|
||||
{leg.departure.scheduled.airportCode}
|
||||
</span>
|
||||
<span className="flight-details__station-name">
|
||||
{leg.departure.scheduled.airport}
|
||||
</span>
|
||||
<span className="flight-details__station-city">
|
||||
{leg.departure.scheduled.city}
|
||||
</span>
|
||||
{leg.departure.terminal && (
|
||||
<span className="flight-details__terminal">
|
||||
Terminal {leg.departure.terminal}
|
||||
</span>
|
||||
)}
|
||||
{leg.departure.gate && (
|
||||
<span className="flight-details__gate">Gate {leg.departure.gate}</span>
|
||||
)}
|
||||
<span className="flight-details__time">
|
||||
{leg.departure.times.scheduledDeparture.local}
|
||||
</span>
|
||||
{leg.departure.times.actualBlockOff && (
|
||||
<span className="flight-details__time-actual">
|
||||
Actual: {leg.departure.times.actualBlockOff.local}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flight-details__leg-duration">
|
||||
<span>{leg.flyingTime}</span>
|
||||
</div>
|
||||
|
||||
<div className="flight-details__leg-arrival">
|
||||
<span className="flight-details__station-code">
|
||||
{leg.arrival.scheduled.airportCode}
|
||||
</span>
|
||||
<span className="flight-details__station-name">
|
||||
{leg.arrival.scheduled.airport}
|
||||
</span>
|
||||
<span className="flight-details__station-city">
|
||||
{leg.arrival.scheduled.city}
|
||||
</span>
|
||||
{leg.arrival.terminal && (
|
||||
<span className="flight-details__terminal">
|
||||
Terminal {leg.arrival.terminal}
|
||||
</span>
|
||||
)}
|
||||
{leg.arrival.bagBelt && (
|
||||
<span className="flight-details__bag-belt">
|
||||
Baggage belt {leg.arrival.bagBelt}
|
||||
</span>
|
||||
)}
|
||||
<span className="flight-details__time">
|
||||
{leg.arrival.times.scheduledArrival.local}
|
||||
</span>
|
||||
{leg.arrival.times.actualBlockOn && (
|
||||
<span className="flight-details__time-actual">
|
||||
Actual: {leg.arrival.times.actualBlockOn.local}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leg.equipment.name && (
|
||||
<div className="flight-details__aircraft">
|
||||
Aircraft: {leg.equipment.name}
|
||||
{leg.equipment.code ? ` (${leg.equipment.code})` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract legs from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getLegs(flight: { routeType: string; leg?: IFlightLeg; legs?: IFlightLeg[] }): IFlightLeg[] {
|
||||
if (flight.routeType === "Direct" && "leg" in flight && flight.leg) {
|
||||
return [flight.leg];
|
||||
}
|
||||
if ("legs" in flight && flight.legs) {
|
||||
return flight.legs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flight details page. Displays comprehensive flight information
|
||||
* with live updates via SignalR.
|
||||
*/
|
||||
export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
flightId,
|
||||
}) => {
|
||||
// Fetch flight details
|
||||
const detailsParams = {
|
||||
flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`,
|
||||
dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}`,
|
||||
};
|
||||
const { flight, loading, error } = useFlightDetails(detailsParams);
|
||||
|
||||
// Live updates via SignalR
|
||||
const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
|
||||
flightId,
|
||||
flight,
|
||||
);
|
||||
|
||||
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
|
||||
|
||||
if (loading) {
|
||||
return <FlightListSkeleton count={1} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flight-details flight-details--error" data-testid="flight-details-error">
|
||||
<p>Failed to load flight details. Please try again.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayFlight) {
|
||||
return (
|
||||
<div className="flight-details flight-details--not-found" data-testid="flight-details-not-found">
|
||||
<p>Flight not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const legs = getLegs(displayFlight);
|
||||
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
|
||||
|
||||
return (
|
||||
<div className="flight-details" data-testid="flight-details">
|
||||
{/* Connection status */}
|
||||
<div className="flight-details__status" data-testid="connection-status">
|
||||
{connectionStatus === "live" && (
|
||||
<span className="connection-badge connection-badge--live">Live</span>
|
||||
)}
|
||||
{connectionStatus === "reconnecting" && (
|
||||
<span className="connection-badge connection-badge--reconnecting">Reconnecting...</span>
|
||||
)}
|
||||
{connectionStatus === "offline" && (
|
||||
<span className="connection-badge connection-badge--offline">Offline</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight header */}
|
||||
<div className="flight-details__header">
|
||||
<h1 className="flight-details__flight-number">{flightNumber}</h1>
|
||||
<span className="flight-details__overall-status">{displayFlight.status}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary card */}
|
||||
<FlightCard flight={displayFlight} />
|
||||
|
||||
{/* Operating carrier */}
|
||||
{displayFlight.operatingBy.carrier && (
|
||||
<div className="flight-details__operating" data-testid="operating-carrier">
|
||||
Operated by: {displayFlight.operatingBy.carrier}
|
||||
{displayFlight.operatingBy.flightNumber
|
||||
? ` ${displayFlight.operatingBy.flightNumber}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed leg information */}
|
||||
<FlightLegs legs={legs} />
|
||||
|
||||
{/* Flying time */}
|
||||
<div className="flight-details__flying-time" data-testid="flying-time">
|
||||
Total flying time: {displayFlight.flyingTime}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for OnlineBoardSearchPage component.
|
||||
*
|
||||
* Verifies rendering with mock providers and navigation wiring.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { OnlineBoardSearchPage } from "./OnlineBoardSearchPage.js";
|
||||
import type { OnlineBoardSearchPageProps } from "./OnlineBoardSearchPage.js";
|
||||
|
||||
// Mock all hooks and router
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
||||
useOnlineBoard: () => ({
|
||||
flights: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
|
||||
useLiveBoardSearch: (_params: unknown, initialFlights: unknown) => ({
|
||||
flights: initialFlights,
|
||||
connectionStatus: "idle" as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useCalendarDays.js", () => ({
|
||||
useCalendarDays: () => ({
|
||||
days: [],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("OnlineBoardSearchPage", () => {
|
||||
const departureParsedParams: OnlineBoardSearchPageProps["params"] = {
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders search page container", () => {
|
||||
render(<OnlineBoardSearchPage params={departureParsedParams} />);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders empty flight list when no results", () => {
|
||||
render(<OnlineBoardSearchPage params={departureParsedParams} />);
|
||||
expect(screen.getByText("No flights found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for flight search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for route search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for arrival search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "arrival",
|
||||
station: "JFK",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Shared search results page for all 4 online board search types.
|
||||
*
|
||||
* Each route page (flight, departure, arrival, route) composes this
|
||||
* component with its parsed params. This component handles:
|
||||
* - Converting parsed URL params to API search params
|
||||
* - Wiring useOnlineBoard for data fetching
|
||||
* - Wiring useLiveBoardSearch for live SignalR updates
|
||||
* - Rendering FlightList with results
|
||||
* - Navigation to flight details on card click
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "@modern-js/runtime/router";
|
||||
import { FlightList } from "@/ui/flights/FlightList.js";
|
||||
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
|
||||
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
||||
import { buildOnlineBoardUrl, buildFlightUrlParams } from "../url.js";
|
||||
import type { OnlineBoardParams } from "../url.js";
|
||||
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
||||
import type { FlightRequestType, ISimpleFlight } from "../types.js";
|
||||
|
||||
export interface OnlineBoardSearchPageProps {
|
||||
/** Parsed and validated URL params from the route */
|
||||
params: OnlineBoardParams & { type: "flight" | "departure" | "arrival" | "route" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed online board URL params into API search params.
|
||||
* The API expects dateFrom/dateTo (same day for single-date searches).
|
||||
*/
|
||||
function toSearchParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): SearchFlightsParams {
|
||||
const base: SearchFlightsParams = {
|
||||
dateFrom: params.date,
|
||||
dateTo: params.date,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
break;
|
||||
case "arrival":
|
||||
base.arrival = params.station;
|
||||
break;
|
||||
case "route":
|
||||
base.departure = params.departure;
|
||||
base.arrival = params.arrival;
|
||||
break;
|
||||
}
|
||||
|
||||
if ("timeFrom" in params && params.timeFrom) {
|
||||
base.timeFrom = params.timeFrom;
|
||||
}
|
||||
if ("timeTo" in params && params.timeTo) {
|
||||
base.timeTo = params.timeTo;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed params into calendar API params.
|
||||
*/
|
||||
function toCalendarParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): CalendarParams {
|
||||
const base: CalendarParams = {
|
||||
date: params.date,
|
||||
searchType: params.type as FlightRequestType,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
break;
|
||||
case "arrival":
|
||||
base.arrival = params.station;
|
||||
break;
|
||||
case "route":
|
||||
base.departure = params.departure;
|
||||
base.arrival = params.arrival;
|
||||
break;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract live board channel params from parsed URL params.
|
||||
*/
|
||||
function toLiveBoardParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): { date: string; departure?: string; arrival?: string } {
|
||||
const result: { date: string; departure?: string; arrival?: string } = {
|
||||
date: params.date,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "departure":
|
||||
result.departure = params.station;
|
||||
break;
|
||||
case "arrival":
|
||||
result.arrival = params.station;
|
||||
break;
|
||||
case "route":
|
||||
result.departure = params.departure;
|
||||
result.arrival = params.arrival;
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared search results page composed by all 4 search route pages.
|
||||
*/
|
||||
export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
params,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
// Data fetching
|
||||
const searchParams = toSearchParams(params);
|
||||
const { flights, loading, error, refresh } = useOnlineBoard(searchParams);
|
||||
|
||||
// Live updates via SignalR
|
||||
const liveBoardParams = toLiveBoardParams(params);
|
||||
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
|
||||
liveBoardParams,
|
||||
flights,
|
||||
);
|
||||
|
||||
// Calendar days
|
||||
const calendarParams = toCalendarParams(params);
|
||||
const { days: calendarDays } = useCalendarDays(calendarParams);
|
||||
|
||||
// Navigation: click a flight to go to details
|
||||
const handleFlightClick = useCallback(
|
||||
(flight: ISimpleFlight) => {
|
||||
const detailsUrl = buildOnlineBoardUrl({
|
||||
type: "details",
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
suffix: flight.flightId.suffix || undefined,
|
||||
date: flight.flightId.date,
|
||||
});
|
||||
void navigate(`/${lang}/${detailsUrl}`);
|
||||
},
|
||||
[navigate, lang],
|
||||
);
|
||||
|
||||
// Navigation: change date via calendar
|
||||
const handleDateChange = useCallback(
|
||||
(newDate: string) => {
|
||||
const newParams = { ...params, date: newDate };
|
||||
const url = buildOnlineBoardUrl(newParams);
|
||||
void navigate(`/${lang}/${url}`);
|
||||
},
|
||||
[navigate, lang, params],
|
||||
);
|
||||
|
||||
// Use live flights when connected, otherwise fetched flights
|
||||
const displayFlights = connectionStatus === "live" ? liveFlights : flights;
|
||||
|
||||
return (
|
||||
<div className="online-board-search" data-testid="online-board-search">
|
||||
{/* Connection status indicator */}
|
||||
<div className="online-board-search__status" data-testid="connection-status">
|
||||
{connectionStatus === "live" && (
|
||||
<span className="connection-badge connection-badge--live">Live</span>
|
||||
)}
|
||||
{connectionStatus === "reconnecting" && (
|
||||
<span className="connection-badge connection-badge--reconnecting">Reconnecting...</span>
|
||||
)}
|
||||
{connectionStatus === "offline" && (
|
||||
<span className="connection-badge connection-badge--offline">Offline</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar strip (simple date list for now) */}
|
||||
{calendarDays.length > 0 && (
|
||||
<div className="online-board-search__calendar" data-testid="calendar-strip">
|
||||
{calendarDays.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`calendar-day${day === params.date ? " calendar-day--active" : ""}`}
|
||||
onClick={() => handleDateChange(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="online-board-search__error" data-testid="search-error">
|
||||
<p>Failed to load flights. Please try again.</p>
|
||||
<button type="button" onClick={refresh}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flight list */}
|
||||
<FlightList flights={displayFlights} loading={loading} />
|
||||
|
||||
{/* Flight click overlay — we make the list clickable */}
|
||||
{!loading && displayFlights.length > 0 && (
|
||||
<div className="online-board-search__actions" data-testid="flight-actions">
|
||||
{displayFlights.map((flight) => (
|
||||
<button
|
||||
key={flight.id}
|
||||
type="button"
|
||||
className="flight-detail-link"
|
||||
data-testid={`flight-link-${flight.id}`}
|
||||
onClick={() => handleFlightClick(flight)}
|
||||
>
|
||||
View details for {flight.flightId.carrier} {flight.flightId.flightNumber}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for OnlineBoardStartPage component.
|
||||
*
|
||||
* Verifies form rendering with different search modes and submit behavior.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { OnlineBoardStartPage } from "./OnlineBoardStartPage.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
}));
|
||||
|
||||
describe("OnlineBoardStartPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders start page with search form", () => {
|
||||
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", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
expect(screen.getByLabelText("Flight")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Departure")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Arrival")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Route")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows flight number input by default (flight mode)", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
expect(screen.getByTestId("flight-number-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to departure mode and shows departure input", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.click(screen.getByLabelText("Departure"));
|
||||
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to route mode and shows both airport inputs", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.click(screen.getByLabelText("Route"));
|
||||
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
|
||||
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits flight search and navigates", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("does not submit when flight number is empty", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Online Board start page — search form with tabs for different search modes.
|
||||
*
|
||||
* No API calls on load. Pure form that navigates to the appropriate
|
||||
* search route on submit.
|
||||
*
|
||||
* @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)}`;
|
||||
}
|
||||
|
||||
export const OnlineBoardStartPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user