From 23fe6ae88dc794daef18aefea49d223258f40b47 Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 16 Apr 2026 23:26:18 +0300 Subject: [PATCH] Add FlightsMiniListItem component with Link navigation --- .../FlightsMiniList/FlightsMiniList.scss | 74 +++++++++++++ .../FlightsMiniListItem.test.tsx | 103 ++++++++++++++++++ .../FlightsMiniList/FlightsMiniListItem.tsx | 78 +++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss create mode 100644 src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx create mode 100644 src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx diff --git a/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss b/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss new file mode 100644 index 00000000..049b7e17 --- /dev/null +++ b/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss @@ -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; + } +} diff --git a/src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx b/src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx new file mode 100644 index 00000000..59ff5c08 --- /dev/null +++ b/src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx @@ -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 }) => ( + {children} + ), +})); + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeDirectFlight(overrides: Partial = {}): 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(); + expect(screen.getByText(/SU\s*0022/)).toBeTruthy(); + }); + + it("renders departure and arrival times", () => { + const flight = makeDirectFlight(); + render(); + expect(screen.getByText("10:00")).toBeTruthy(); + expect(screen.getByText("12:30")).toBeTruthy(); + }); + + it("renders departure and arrival station codes", () => { + const flight = makeDirectFlight(); + render(); + expect(screen.getByText("SVO")).toBeTruthy(); + expect(screen.getByText("LED")).toBeTruthy(); + }); + + it("has data-testid based on flight id", () => { + const flight = makeDirectFlight(); + render(); + expect(screen.getByTestId("mini-list-item-SU0022-20260416")).toBeTruthy(); + }); + + it("applies selected modifier when isSelected", () => { + const flight = makeDirectFlight(); + render(); + 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(); + 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(); + const link = screen.getByTestId("mini-list-item-SU0022-20260416") as HTMLAnchorElement; + expect(link.getAttribute("href")).toBe("/ru/onlineboard/SU0022-20260416"); + }); +}); diff --git a/src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx b/src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx new file mode 100644 index 00000000..cca73d22 --- /dev/null +++ b/src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx @@ -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( + ({ 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 ( + +
+ {flight.flightId.carrier} {flight.flightId.flightNumber} +
+
+ {getDepTime(dep)} + + {"\u2708"} + + {getArrTime(arr)} + {dep.scheduled.airportCode} + {arr.scheduled.airportCode} +
+ + ); + }, +); + +FlightsMiniListItem.displayName = "FlightsMiniListItem";