Add FlightsMiniListItem component with Link navigation
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
.mini-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 170px);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
|
||||
&__item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border: 2px solid #2060c0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__flight-number {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
&__dep-time,
|
||||
&__arr-time {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a3a5c;
|
||||
}
|
||||
|
||||
&__arr-time {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__status-icon {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&__dep-station {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&__arr-station {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tests for FlightsMiniListItem — a single row in the flights mini-list sidebar.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FlightsMiniListItem } from "./FlightsMiniListItem.js";
|
||||
import type { ISimpleFlight } from "../../types.js";
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
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: (k: string) => k }),
|
||||
}));
|
||||
|
||||
function makeDirectFlight(overrides: Partial<ISimpleFlight> = {}): ISimpleFlight {
|
||||
return {
|
||||
id: "SU0022-20260416",
|
||||
routeType: "Direct" as const,
|
||||
flyingTime: "1h 30m",
|
||||
status: "Scheduled" as const,
|
||||
flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260416" },
|
||||
operatingBy: {},
|
||||
leg: {
|
||||
arrival: {
|
||||
scheduled: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" },
|
||||
latest: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" },
|
||||
dispatch: "", gate: "", terminal: "",
|
||||
times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "09:30" } },
|
||||
},
|
||||
dayChange: 0,
|
||||
departure: {
|
||||
scheduled: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" },
|
||||
latest: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" },
|
||||
dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
|
||||
times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: 3, utc: "07:00" } },
|
||||
},
|
||||
equipment: {},
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
flyingTime: "1h 30m",
|
||||
index: 0,
|
||||
operatingBy: {},
|
||||
status: "Scheduled" as const,
|
||||
updated: "",
|
||||
},
|
||||
...overrides,
|
||||
} as ISimpleFlight;
|
||||
}
|
||||
|
||||
describe("FlightsMiniListItem", () => {
|
||||
it("renders flight number", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
expect(screen.getByText(/SU\s*0022/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders departure and arrival times", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
expect(screen.getByText("10:00")).toBeTruthy();
|
||||
expect(screen.getByText("12:30")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders departure and arrival station codes", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
expect(screen.getByText("SVO")).toBeTruthy();
|
||||
expect(screen.getByText("LED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has data-testid based on flight id", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
expect(screen.getByTestId("mini-list-item-SU0022-20260416")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies selected modifier when isSelected", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={true} lang="ru" />);
|
||||
const item = screen.getByTestId("mini-list-item-SU0022-20260416");
|
||||
expect(item.className).toMatch(/--selected/);
|
||||
});
|
||||
|
||||
it("does not apply selected modifier when not selected", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
const item = screen.getByTestId("mini-list-item-SU0022-20260416");
|
||||
expect(item.className).not.toMatch(/--selected/);
|
||||
});
|
||||
|
||||
it("link points to the flight details URL", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
const link = screen.getByTestId("mini-list-item-SU0022-20260416") as HTMLAnchorElement;
|
||||
expect(link.getAttribute("href")).toBe("/ru/onlineboard/SU0022-20260416");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* FlightsMiniListItem — a single row in the flights mini-list sidebar.
|
||||
*
|
||||
* Renders a Link with flight number, departure/arrival times and station codes.
|
||||
* Applies a `--selected` modifier when the item matches the currently-viewed flight.
|
||||
*/
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { Link } from "@modern-js/runtime/router";
|
||||
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
|
||||
import { buildOnlineBoardUrl } from "../../url.js";
|
||||
import "./FlightsMiniList.scss";
|
||||
|
||||
export interface FlightsMiniListItemProps {
|
||||
flight: ISimpleFlight;
|
||||
isSelected: boolean;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first-leg departure and last-leg arrival for display.
|
||||
* Direct flights use the single leg; MultiLeg uses first and last.
|
||||
*/
|
||||
function getEndpoints(flight: ISimpleFlight): { dep: IFlightLeg["departure"]; arr: IFlightLeg["arrival"] } {
|
||||
if (flight.routeType === "Direct") {
|
||||
return { dep: flight.leg.departure, arr: flight.leg.arrival };
|
||||
}
|
||||
const firstLeg = flight.legs[0]!;
|
||||
const lastLeg = flight.legs[flight.legs.length - 1]!;
|
||||
return { dep: firstLeg.departure, arr: lastLeg.arrival };
|
||||
}
|
||||
|
||||
function getDepTime(dep: IFlightLeg["departure"]): string {
|
||||
return dep.times.actualBlockOff?.local ?? dep.times.scheduledDeparture.local;
|
||||
}
|
||||
|
||||
function getArrTime(arr: IFlightLeg["arrival"]): string {
|
||||
return arr.times.actualBlockOn?.local ?? arr.times.scheduledArrival.local;
|
||||
}
|
||||
|
||||
export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniListItemProps>(
|
||||
({ flight, isSelected, lang }, ref) => {
|
||||
const { dep, arr } = getEndpoints(flight);
|
||||
const href = `/${lang}/${buildOnlineBoardUrl({
|
||||
type: "details",
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}),
|
||||
date: flight.flightId.date,
|
||||
})}`;
|
||||
|
||||
const className = `mini-list__item${isSelected ? " mini-list__item--selected" : ""}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={href}
|
||||
className={className}
|
||||
data-testid={`mini-list-item-${flight.id}`}
|
||||
>
|
||||
<div className="mini-list__flight-number">
|
||||
{flight.flightId.carrier} {flight.flightId.flightNumber}
|
||||
</div>
|
||||
<div className="mini-list__content">
|
||||
<span className="mini-list__dep-time">{getDepTime(dep)}</span>
|
||||
<span className="mini-list__status-icon" aria-label={flight.status}>
|
||||
{"\u2708"}
|
||||
</span>
|
||||
<span className="mini-list__arr-time">{getArrTime(arr)}</span>
|
||||
<span className="mini-list__dep-station">{dep.scheduled.airportCode}</span>
|
||||
<span className="mini-list__arr-station">{arr.scheduled.airportCode}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlightsMiniListItem.displayName = "FlightsMiniListItem";
|
||||
Reference in New Issue
Block a user