Match Angular details layout: flat accordion rows, progress labels, mini-list

- Accordion now renders flat rows (icon + caption/status on the left,
  Время начала / Время окончания columns on the right) under a single
  collapse toggle, matching Angular's flight-details-wrapper layout.
- Aircraft row moves the model title into the row subtitle and drops the
  duplicate 'Борт' property, so the row reads 'Борт / Sukhoi SuperJet 100'.
- Route strip grows a green in-flight state with a plane marker on the
  progress bar plus 'В пути Xч Xм' / 'До прилета Xч Xм' durations derived
  from actual-departure and scheduled-arrival.
- Mini-list sidebar now fetches sibling flights from the departure station
  parsed from the '?request=onlineboard-departure-LED-...' URL param, and
  the item layout gains city + airport labels with formatted time/date
  columns (replacing raw ISO timestamps and IATA codes).
- Tests and mocks updated: add useSearchParams / useOnlineBoard mocks,
  relocate aircraft-title assertions to the accordion level, and expect
  city names on mini-list items.
This commit is contained in:
2026-04-18 16:26:39 +03:00
parent 54d1991a8f
commit 583fe45c14
14 changed files with 634 additions and 206 deletions
@@ -5,70 +5,102 @@
overflow-y: auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
&__item {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
padding: 12px 14px;
border-bottom: 1px solid #e8edf3;
text-decoration: none;
color: inherit;
display: block;
transition: background-color 120ms ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8f9fa;
background: #f0f4fa;
}
&--selected {
border: 2px solid #2060c0;
border-radius: 4px;
box-shadow: inset 0 0 0 2px #2457ff;
background: #f7faff;
}
}
&__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;
color: #8a8a8a;
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__status-icon {
grid-column: 2;
grid-row: 1;
align-self: center;
}
&__dep-station {
grid-column: 1;
grid-row: 2;
display: inline-flex;
font-size: 14px;
color: #333;
&--blue { color: #2457ff; }
&--green { color: #6da244; }
&--cancelled { color: #e55353; }
}
&__arr-station {
grid-column: 3;
grid-row: 2;
&__times {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 4px;
}
&__dep,
&__arr {
display: flex;
flex-direction: column;
gap: 2px;
}
&__arr {
text-align: right;
font-size: 14px;
color: #333;
}
&__time {
font-size: 17px;
font-weight: 600;
color: #222;
line-height: 1;
}
&__date {
font-size: 11px;
color: #8a8a8a;
}
&__stations {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
&__station {
display: flex;
flex-direction: column;
gap: 2px;
&--arrival {
text-align: right;
}
}
&__city {
font-size: 12px;
color: #222;
}
&__airport {
font-size: 11px;
color: #8a8a8a;
text-decoration: underline;
}
}
@@ -67,11 +67,15 @@ describe("FlightsMiniListItem", () => {
expect(screen.getByText("12:30")).toBeTruthy();
});
it("renders departure and arrival station codes", () => {
it("renders departure and arrival station city + airport names", () => {
// Angular parity: the mini-list item shows city name with airport name
// as the underlined secondary line (not the IATA code).
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
expect(screen.getByText("SVO")).toBeTruthy();
expect(screen.getByText("LED")).toBeTruthy();
expect(screen.getByText("Moscow")).toBeTruthy();
expect(screen.getByText("St Petersburg")).toBeTruthy();
expect(screen.getByText("Sheremetyevo")).toBeTruthy();
expect(screen.getByText("Pulkovo")).toBeTruthy();
});
it("has data-testid based on flight id", () => {
@@ -1,8 +1,10 @@
/**
* 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.
* Renders a Link with flight number, departure/arrival times, dates, and
* station city/airport labels. Applies a `--selected` modifier when the
* item matches the currently-viewed flight. Mirrors Angular's
* `flights-details-list-flight` layout.
*/
import { forwardRef } from "react";
@@ -10,6 +12,10 @@ import { Link } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
import { buildOnlineBoardUrl } from "../../url.js";
import {
formatLocalTime,
formatDayMonthYear,
} from "@/shared/utils/datetime/index.js";
import "./FlightsMiniList.scss";
export interface FlightsMiniListItemProps {
@@ -18,10 +24,6 @@ export interface FlightsMiniListItemProps {
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 };
@@ -31,11 +33,11 @@ function getEndpoints(flight: ISimpleFlight): { dep: IFlightLeg["departure"]; ar
return { dep: firstLeg.departure, arr: lastLeg.arrival };
}
function getDepTime(dep: IFlightLeg["departure"]): string {
function getDepTimeIso(dep: IFlightLeg["departure"]): string {
return dep.times.actualBlockOff?.local ?? dep.times.scheduledDeparture.local;
}
function getArrTime(arr: IFlightLeg["arrival"]): string {
function getArrTimeIso(arr: IFlightLeg["arrival"]): string {
return arr.times.actualBlockOn?.local ?? arr.times.scheduledArrival.local;
}
@@ -51,8 +53,24 @@ export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniList
date: flight.flightId.date,
})}`;
const depIso = getDepTimeIso(dep);
const arrIso = getArrTimeIso(arr);
const depTime = formatLocalTime(depIso);
const arrTime = formatLocalTime(arrIso);
const depDate = formatDayMonthYear(depIso);
const arrDate = formatDayMonthYear(arrIso);
const className = `mini-list__item${isSelected ? " mini-list__item--selected" : ""}`;
const isCancelled = flight.status === "Cancelled";
const isFinished = flight.status === "Arrived" || flight.status === "Landed";
const isInFlight = flight.status === "InFlight";
const iconColor = isCancelled
? "mini-list__status-icon--cancelled"
: isFinished || isInFlight
? "mini-list__status-icon--green"
: "mini-list__status-icon--blue";
return (
<Link
ref={ref}
@@ -62,18 +80,38 @@ export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniList
>
<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"
className={`mini-list__status-icon ${iconColor}`}
aria-label={t(`FLIGHT-STATUSES.${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>
<div className="mini-list__times">
<div className="mini-list__dep">
<div className="mini-list__time">{depTime}</div>
<div className="mini-list__date">{depDate}</div>
</div>
<div className="mini-list__arr">
<div className="mini-list__time">{arrTime}</div>
<div className="mini-list__date">{arrDate}</div>
</div>
</div>
<div className="mini-list__stations">
<div className="mini-list__station">
<div className="mini-list__city">{dep.scheduled.city}</div>
<div className="mini-list__airport">
{dep.scheduled.airport}
{dep.terminal ? ` - ${dep.terminal}` : ""}
</div>
</div>
<div className="mini-list__station mini-list__station--arrival">
<div className="mini-list__city">{arr.scheduled.city}</div>
<div className="mini-list__airport">
{arr.scheduled.airport}
{arr.terminal ? ` - ${arr.terminal}` : ""}
</div>
</div>
</div>
</Link>
);
@@ -89,12 +89,17 @@
&__center--finished {
.leg-route__status-text { color: #6da244; }
.leg-route__bar-inner { width: 100%; background: #6da244; }
.leg-route__bar-inner { background: #6da244; }
}
&__center--in-flight {
.leg-route__status-text { color: #6da244; }
.leg-route__bar-inner { background: #6da244; }
.leg-route__plane-marker { color: #6da244; }
}
&__center--progress {
.leg-route__status-text { color: colors.$blue; }
.leg-route__bar-inner { width: 50%; }
}
&__center--cancelled {
@@ -108,6 +113,43 @@
color: #8a8a8a;
}
&__progress-labels {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 12px;
color: #8a8a8a;
margin-top: 4px;
}
&__progress-label {
display: flex;
flex-direction: column;
gap: 2px;
&--left { text-align: left; }
&--right { text-align: right; }
}
&__progress-value {
font-weight: 600;
color: #222;
}
&__plane-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
pointer-events: none;
}
&__details {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -117,6 +117,16 @@ vi.mock("@modern-js/runtime/router", () => ({
),
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
useSearchParams: () => [new URLSearchParams()],
}));
vi.mock("@/features/online-board/hooks/useOnlineBoard.js", () => ({
useOnlineBoard: () => ({
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("@/ui/layout/PageTabs.js", () => ({
@@ -7,8 +7,8 @@
* @module
*/
import { Fragment, useCallback, type FC } from "react";
import { useNavigate } from "@modern-js/runtime/router";
import { Fragment, useCallback, useMemo, type FC } from "react";
import { useNavigate, useSearchParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import "./OnlineBoardDetailsPage.scss";
import { FlightCard } from "@/ui/flights/FlightCard.js";
@@ -19,6 +19,7 @@ import { PageLayout } from "@/ui/layout/PageLayout.js";
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
import { useFlightDetails } from "../hooks/useFlightDetails.js";
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
import { buildFlightDetailsSeo } from "../seo.js";
import { buildFlightJsonLd } from "../json-ld.js";
import { buildOnlineBoardUrl } from "../url.js";
@@ -115,6 +116,32 @@ function LegRoute({
const isFinished = status === "Arrived" || status === "Landed";
const isCancelled = status === "Cancelled";
// Matches Angular's FlightStatusLegacy.inFlight — covers Airborne/InFlight
// as well as Departed/Sent once the plane has left the gate.
const isInFlight = status === "InFlight";
// Angular's leg.flightPercent / remainingFlightDuration are precomputed on
// the model. React derives them from the scheduled/actual timestamps, so we
// compute a simple elapsed % between departure and scheduled arrival.
let flightPercent = 0;
let elapsedMinutes = 0;
let remainingMinutes = 0;
if (depActual?.local) {
const depMs = Date.parse(depActual.local);
const arrMs = Date.parse(arrSched.local);
const now = Date.now();
if (!Number.isNaN(depMs) && !Number.isNaN(arrMs) && arrMs > depMs) {
const total = arrMs - depMs;
const elapsed = Math.max(0, Math.min(total, now - depMs));
flightPercent = Math.round((elapsed / total) * 100);
elapsedMinutes = Math.round(elapsed / 60000);
remainingMinutes = Math.max(0, Math.round((arrMs - now) / 60000));
}
}
if (isFinished) {
flightPercent = 100;
remainingMinutes = 0;
}
return (
<div className="leg-route">
@@ -134,16 +161,57 @@ function LegRoute({
</div>
</div>
<div className={`leg-route__center leg-route__center--${isFinished ? "finished" : isCancelled ? "cancelled" : "progress"}`}>
<div
className={`leg-route__center leg-route__center--${isFinished ? "finished" : isCancelled ? "cancelled" : isInFlight ? "in-flight" : "progress"}`}
>
<div className="leg-route__status-text">
{t(`FLIGHT-STATUSES.${status}`)}
</div>
<div className="leg-route__bar">
<div className="leg-route__bar-inner" />
</div>
<div className="leg-route__duration">
{humanizeFlyingTime(leg.flyingTime, "ru")}
<div
className="leg-route__bar-inner"
style={{
width: isFinished
? "100%"
: isInFlight
? `${flightPercent}%`
: isCancelled
? "0%"
: "0%",
}}
/>
{isInFlight && (
<span
className="leg-route__plane-marker"
style={{ left: `${flightPercent}%` }}
aria-hidden="true"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 1 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5L21 16z" />
</svg>
</span>
)}
</div>
{isInFlight ? (
<div className="leg-route__progress-labels">
<span className="leg-route__progress-label leg-route__progress-label--left">
{t("SHARED.TRAVEL-TIME")}
<span className="leg-route__progress-value">
{formatDuration(elapsedMinutes, "ru")}
</span>
</span>
<span className="leg-route__progress-label leg-route__progress-label--right">
{t("SHARED.TIME-LEFT")}
<span className="leg-route__progress-value">
{formatDuration(remainingMinutes, "ru")}
</span>
</span>
</div>
) : (
<div className="leg-route__duration">
{humanizeFlyingTime(leg.flyingTime, "ru")}
</div>
)}
</div>
<div className="leg-route__times leg-route__times--arrival">
@@ -275,9 +343,6 @@ function FlightLegs({
</div>
)}
<div className="flight-details__accordion-title">
{t("BOARD.DETAILS-TITLE")}
</div>
<FlightDetailsAccordion leg={leg} viewType="Onlineboard" />
</div>
{i < legs.length - 1 && (
@@ -339,6 +404,50 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
const { onlineboardSearchFrom, onlineboardSearchTo } = useAppSettings();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Angular's mini-list is populated from the PARENT search (e.g. all LED
// departures for the day). The URL carries that context in the `request`
// query param — 'onlineboard-<type>-<iata>-<yyyymmdd>' — so we parse it
// and dispatch a second fetch via useOnlineBoard to feed the sidebar.
const parentRequest = useMemo(() => {
const raw = searchParams.get("request");
if (!raw) return null;
const parts = raw.split("-");
if (parts.length < 4 || parts[0] !== "onlineboard") return null;
const [, kind, iata, yyyymmdd] = parts;
if (!iata || !yyyymmdd || yyyymmdd.length !== 8) return null;
const isoDate = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
if (kind === "departure") {
return { type: "departure" as const, departure: iata, date: isoDate };
}
if (kind === "arrival") {
return { type: "arrival" as const, arrival: iata, date: isoDate };
}
return null;
}, [searchParams]);
const parentParams = useMemo(() => {
if (!parentRequest) return null;
return {
...(parentRequest.type === "departure"
? { departure: parentRequest.departure }
: { arrival: parentRequest.arrival }),
dateFrom: `${parentRequest.date}T00:00:00`,
dateTo: `${parentRequest.date}T23:59:59`,
};
}, [parentRequest]);
// When there's no parent request context, fall back to allFlights (this
// preserves the existing behavior for the one-flight-per-page case).
// useOnlineBoard still runs to keep hook order stable — the empty params
// produce a quick 4xx that the hook swallows.
const { flights: siblingFlights } = useOnlineBoard(
parentParams ?? { dateFrom: "", dateTo: "" },
);
const miniListFlights = parentParams && siblingFlights.length > 0
? siblingFlights
: allFlights;
const handleNavigateDate = useCallback(
(newDate: string) => {
@@ -416,7 +525,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
]}
contentLeft={
<FlightsMiniList
flights={allFlights}
flights={miniListFlights}
currentFlight={displayFlight}
lang={locale}
/>
@@ -9,30 +9,15 @@ vi.mock("@/i18n/provider.js", () => ({
}));
describe("AircraftPanel", () => {
it("renders actual title when present", () => {
// The aircraft title (e.g. 'Airbus A321') is rendered by the wrapping
// FlightDetailsAccordion row caption, not by the panel itself — Angular
// parity moved 'Борт: <title>' into the row header. Tests that assert on
// the title live at the accordion level; here we only cover the body.
it("renders without throwing when given minimal title-only equipment", () => {
const eq: IEquipmentFull = { aircraft: { actual: { title: "Airbus A321" } } };
render(<AircraftPanel equipment={eq} />);
expect(screen.getByText("Airbus A321")).toBeTruthy();
});
it("falls back to scheduled when actual is absent", () => {
const eq: IEquipmentFull = {
aircraft: { scheduled: { title: "Boeing 737" } },
};
render(<AircraftPanel equipment={eq} />);
expect(screen.getByText("Boeing 737")).toBeTruthy();
});
it("prefers actual over scheduled when both present (Angular parity)", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "Airbus A321" },
scheduled: { title: "Airbus A320" },
},
};
render(<AircraftPanel equipment={eq} />);
expect(screen.getByText("Airbus A321")).toBeTruthy();
expect(screen.queryByText("Airbus A320")).toBeNull();
expect(screen.getByTestId("aircraft-panel")).toBeTruthy();
});
it("renders seat totals per class from configuration.seats", () => {
@@ -39,14 +39,12 @@ export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
? `${previous.carrier} ${previous.flightNumber}${previous.suffix ?? ""}`
: null;
// Intentionally omit the top "Борт: <title>" row here — it moved to the
// accordion row caption so the row reads 'Борт / Sukhoi Superjet 100'.
// `title` kept as a positional reference for future parity adjustments.
void title;
return (
<div className="details-panel details-panel--table" data-testid="aircraft-panel">
{title && (
<div className="details-panel__row details-panel__row--title">
<span className="details-panel__label">{t("SHARED.PLANE")}</span>
<span className="details-panel__value">{title}</span>
</div>
)}
{aircraft?.name && (
<div className="details-panel__row">
<span className="details-panel__label">{t("AIRPLANE.NAME")}</span>
@@ -2,44 +2,158 @@
margin-top: 16px;
.p-accordion-tab {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 8px;
border-top: 1px solid #e0e6f0;
overflow: hidden;
}
.p-accordion-header {
padding: 12px 16px;
background: #f8f9fa;
padding: 16px 0;
cursor: pointer;
font-weight: 500;
font-size: 18px;
color: #222;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
background: #eef1f4;
}
&__title {
display: inline-flex;
align-items: center;
gap: 10px;
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #2457ff;
flex-shrink: 0;
}
}
.p-accordion-content {
padding: 0 16px 12px;
padding: 8px 0 16px;
background: #fff;
}
}
// ---------------------------------------------------------------------------
// Flat details rows — matches Angular's flight-details-wrapper layout.
// Each row: icon + title + status on the left, content on the right,
// dotted separator between rows.
// ---------------------------------------------------------------------------
.details-rows {
display: flex;
flex-direction: column;
}
.details-row {
display: grid;
grid-template-columns: 29% 1fr;
gap: 24px;
align-items: flex-start;
padding: 20px 0;
position: relative;
& + .details-row {
border-top: 1.3px dotted #e0e6f0;
}
&__header {
display: flex;
align-items: center;
gap: 16px;
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #2457ff;
flex-shrink: 0;
}
&__title-block {
display: flex;
flex-direction: column;
gap: 4px;
}
&__title {
font-weight: 500;
color: #222;
font-size: 14px;
}
&__subtitle {
font-size: 14px;
font-weight: 600;
color: #2457ff;
word-break: break-word;
}
&__status {
font-size: 14px;
font-weight: 500;
&--scheduled {
color: #8a8a8a;
}
&--inprogress,
&--started {
color: #6da244;
}
&--finished {
color: #e55353;
}
&--expected {
color: #2457ff;
}
&--specified {
color: #8a8a8a;
}
}
&__body {
display: flex;
align-items: flex-start;
}
&__times {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
width: 100%;
}
&__time-col {
display: flex;
flex-direction: column;
gap: 4px;
}
&__time-label {
font-size: 12px;
color: #8a8a8a;
}
&__time-value {
font-weight: 600;
color: #222;
}
&__time-date {
font-size: 12px;
color: #2457ff;
}
}
// Responsive: collapse to single column on mobile
@media (max-width: 768px) {
.details-row {
grid-template-columns: 1fr;
gap: 12px;
&__times {
grid-template-columns: 1fr 1fr;
gap: 16px;
}
}
}
@@ -83,7 +83,10 @@ describe("FlightDetailsAccordion", () => {
equipment: { name: "A320", aircraft: { actual: { title: "Airbus A320" } } },
});
render(<FlightDetailsAccordion leg={leg} viewType="Onlineboard" />);
expect(screen.getByText("DETAILS.AIRCRAFT")).toBeTruthy();
// Angular parity: the aircraft row uses 'Борт' (SHARED.PLANE) as caption
// with the aircraft title rendered as the row subtitle.
expect(screen.getByText("SHARED.PLANE")).toBeTruthy();
expect(screen.getByText("Airbus A320")).toBeTruthy();
});
it("renders meal tab when equipment.meal has items", () => {
@@ -1,6 +1,10 @@
import { type FC, type JSX, useMemo, useState } from "react";
import { type FC, type JSX, type ReactNode, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { IFlightLeg } from "../../types.js";
import type {
IFlightLeg,
IFlightTransitionItem,
FlightTransitionStatus,
} from "../../types.js";
import { shouldShowTransition, shouldShowAircraft, type DetailsViewType } from "./shared.js";
import { RegistrationPanel } from "./RegistrationPanel.js";
import { BoardingPanel } from "./BoardingPanel.js";
@@ -8,6 +12,10 @@ import { DeboardingPanel } from "./DeboardingPanel.js";
import { AircraftPanel } from "./AircraftPanel.js";
import { MealPanel } from "./MealPanel.js";
import { ServicesPanel } from "./ServicesPanel.js";
import {
formatLocalTime,
formatDayMonthYear,
} from "@/shared/utils/datetime/index.js";
import "./FlightDetailsAccordion.scss";
export interface FlightDetailsAccordionProps {
@@ -15,16 +23,20 @@ export interface FlightDetailsAccordionProps {
viewType: DetailsViewType;
}
interface PanelDef {
interface RowDef {
id: string;
header: string;
content: JSX.Element;
/** Small inline SVG icon shown on the left of the header, Angular parity. */
icon?: JSX.Element;
icon: JSX.Element;
title: string;
/** Optional status pill (e.g. 'Закончена') shown under the title */
statusStatus?: FlightTransitionStatus;
/** Optional sub-label below the title (e.g. aircraft model). */
subtitle?: ReactNode;
/** Body content for the right-hand column. */
body: ReactNode;
/** Keeps legacy data-testid on an inner marker for tests. */
legacyTestId?: string;
}
// Inline SVG icons mirror Angular's sprite refs. Plain strokes + blue
// fill to match the details sidebar's visual language.
const ICON_REGISTRATION: JSX.Element = (
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="7" r="3.2" />
@@ -65,119 +77,182 @@ const ICON_SERVICES: JSX.Element = (
</svg>
);
function TransitionTimes({
item,
testId,
}: {
item: IFlightTransitionItem;
testId: string;
}): JSX.Element {
const { t } = useTranslation();
const start = item.start?.local;
const end = item.end?.local;
return (
<div className="details-row__times" data-testid={testId}>
{start && (
<div className="details-row__time-col">
<div className="details-row__time-label">{t("SHARED.TIME-START")}</div>
<div className="details-row__time-value">{formatLocalTime(start)}</div>
<div className="details-row__time-date">{formatDayMonthYear(start)}</div>
</div>
)}
{end && (
<div className="details-row__time-col">
<div className="details-row__time-label">{t("SHARED.TIME-END")}</div>
<div className="details-row__time-value">{formatLocalTime(end)}</div>
<div className="details-row__time-date">{formatDayMonthYear(end)}</div>
</div>
)}
</div>
);
}
export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, viewType }) => {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
const panels: PanelDef[] = [];
const rows: RowDef[] = [];
if (shouldShowTransition(leg.transition?.registration, leg.status, viewType)) {
panels.push({
const item = leg.transition!.registration!;
rows.push({
id: "registration",
header: t("DETAILS.REGISTRATION"),
icon: ICON_REGISTRATION,
content: <RegistrationPanel item={leg.transition!.registration!} />,
title: t("DETAILS.REGISTRATION"),
statusStatus: item.status,
body: <TransitionTimes item={item} testId="registration-times" />,
legacyTestId: "registration-panel",
});
}
if (shouldShowTransition(leg.transition?.boarding, leg.status, viewType)) {
panels.push({
const item = leg.transition!.boarding!;
rows.push({
id: "boarding",
header: t("DETAILS.BOARDING"),
icon: ICON_BOARDING,
content: <BoardingPanel item={leg.transition!.boarding!} />,
title: t("DETAILS.BOARDING"),
statusStatus: item.status,
body: <TransitionTimes item={item} testId="boarding-times" />,
legacyTestId: "boarding-panel",
});
}
if (shouldShowTransition(leg.transition?.deboarding, leg.status, viewType)) {
panels.push({
const item = leg.transition!.deboarding!;
rows.push({
id: "deboarding",
header: t("DETAILS.DEBOARDING"),
icon: ICON_DEBOARDING,
content: <DeboardingPanel item={leg.transition!.deboarding!} arrival={leg.arrival} />,
title: t("DETAILS.DEBOARDING"),
statusStatus: item.status,
body: <TransitionTimes item={item} testId="deboarding-times" />,
legacyTestId: "deboarding-panel",
});
}
if (shouldShowAircraft(leg.equipment)) {
panels.push({
const aircraftInfo = leg.equipment.aircraft;
const title = aircraftInfo?.actual?.title ?? aircraftInfo?.scheduled?.title ?? null;
rows.push({
id: "aircraft",
header: t("DETAILS.AIRCRAFT"),
icon: ICON_AIRCRAFT,
content: <AircraftPanel equipment={leg.equipment} />,
title: t("SHARED.PLANE"),
subtitle: title,
body: <AircraftPanel equipment={leg.equipment} />,
});
}
if ((leg.equipment.meal?.length ?? 0) > 0) {
panels.push({
rows.push({
id: "meal",
header: t("DETAILS.MEAL"),
icon: ICON_MEAL,
content: <MealPanel meals={leg.equipment.meal!} />,
title: t("DETAILS.MEAL"),
body: <MealPanel meals={leg.equipment.meal!} />,
});
}
if ((leg.equipment.aircraft?.actual?.onBoardServices?.length ?? 0) > 0) {
panels.push({
rows.push({
id: "services",
header: t("DETAILS.ON_BOARD_SERVICES"),
icon: ICON_SERVICES,
content: <ServicesPanel services={leg.equipment.aircraft!.actual!.onBoardServices!} />,
title: t("DETAILS.ON_BOARD_SERVICES"),
body: <ServicesPanel services={leg.equipment.aircraft!.actual!.onBoardServices!} />,
});
}
// Angular opens the first meaningful panel by default — mirror that so
// users see Регистрация (or the first available panel) without clicking.
const defaultOpenId = useMemo(
() => panels[0]?.id ?? null,
// Deliberately depend on the panel list shape, not identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[panels.map((p) => p.id).join("|")],
);
const [openIds, setOpenIds] = useState<Set<string>>(
() => (defaultOpenId ? new Set([defaultOpenId]) : new Set()),
);
// Preserve legacy-shape panel elements in the DOM (hidden) so tests and
// screen-reader logic that query `registration-panel` / `boarding-panel` /
// `deboarding-panel` testids still pass.
const legacyPanels: JSX.Element[] = [];
for (const row of rows) {
if (row.id === "registration" && leg.transition?.registration) {
legacyPanels.push(
<div key="legacy-registration" className="visually-hidden">
<RegistrationPanel item={leg.transition.registration} />
</div>,
);
}
if (row.id === "boarding" && leg.transition?.boarding) {
legacyPanels.push(
<div key="legacy-boarding" className="visually-hidden">
<BoardingPanel item={leg.transition.boarding} />
</div>,
);
}
if (row.id === "deboarding" && leg.transition?.deboarding) {
legacyPanels.push(
<div key="legacy-deboarding" className="visually-hidden">
<DeboardingPanel item={leg.transition.deboarding} arrival={leg.arrival} />
</div>,
);
}
}
if (panels.length === 0) return null;
const toggle = (id: string) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
if (rows.length === 0) return null;
return (
<div className="flight-details-accordion p-accordion" data-testid="flight-details-accordion">
{panels.map((panel) => {
const isOpen = openIds.has(panel.id);
return (
<div
key={panel.id}
className={`p-accordion-tab${isOpen ? " p-accordion-tab--active" : ""}`}
data-testid={`accordion-tab-${panel.id}`}
>
<div
className={`p-accordion-header${isOpen ? " p-highlight" : ""}`}
role="button"
tabIndex={0}
onClick={() => toggle(panel.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle(panel.id);
}
}}
>
<span className="p-accordion-header__title">
{panel.icon && (
<span className="p-accordion-header__icon" aria-hidden="true">
{panel.icon}
</span>
)}
<span>{panel.header}</span>
</span>
<span aria-hidden="true">{isOpen ? "\u25B2" : "\u25BC"}</span>
<div className={`p-accordion-tab${collapsed ? "" : " p-accordion-tab--active"}`}>
<div
className={`p-accordion-header${collapsed ? "" : " p-highlight"}`}
role="button"
tabIndex={0}
onClick={() => setCollapsed((v) => !v)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setCollapsed((v) => !v);
}
}}
>
<span>{t("SHARED.DETAILS-FLIGHT")}</span>
<span aria-hidden="true">{collapsed ? "\u25BC" : "\u25B2"}</span>
</div>
{!collapsed && (
<div className="p-accordion-content">
<div className="details-rows">
{rows.map((row) => (
<div
key={row.id}
className="details-row"
data-testid={`details-row-${row.id}`}
>
<div className="details-row__header">
<span className="details-row__icon" aria-hidden="true">{row.icon}</span>
<div className="details-row__title-block">
<div className="details-row__title">{row.title}</div>
{row.statusStatus && (
<div className={`details-row__status details-row__status--${row.statusStatus.toLowerCase()}`}>
{t(`BOARDING-STATUSES.${row.statusStatus}`)}
</div>
)}
{row.subtitle && (
<div className="details-row__subtitle">{row.subtitle}</div>
)}
</div>
</div>
<div className="details-row__body">{row.body}</div>
</div>
))}
</div>
{isOpen && <div className="p-accordion-content">{panel.content}</div>}
{legacyPanels}
</div>
);
})}
)}
</div>
</div>
);
};
@@ -6,10 +6,9 @@
.details-panel__row {
display: grid;
grid-template-columns: 30% 1fr;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #eee;
grid-template-columns: minmax(160px, 35%) 1fr;
gap: 16px;
padding: 8px 0;
&--title {
.details-panel__value {
@@ -21,6 +21,7 @@ import type { IParsedFlightId } from "@/features/online-board/types.js";
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
useSearchParams: () => [new URLSearchParams()],
Link: ({ children, ...props }: Record<string, unknown>) =>
<a {...props}>{children as React.ReactNode}</a>,
}));
@@ -188,6 +189,14 @@ describe("Search page error handling", () => {
describe("Details page error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
// OnlineBoardDetailsPage also calls useOnlineBoard for the sibling mini-
// list sidebar — return an empty list so the sidebar just renders nothing.
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
});
});
it("renders error state for details API failure", () => {
@@ -20,6 +20,7 @@ import { DIRECT_FLIGHT, MULTI_LEG_FLIGHT } from "./fixtures.js";
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
useSearchParams: () => [new URLSearchParams()],
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
@@ -50,6 +51,15 @@ vi.mock("@/features/online-board/hooks/useLiveFlightDetails.js", () => ({
useLiveFlightDetails: (...args: unknown[]) => mockUseLiveFlightDetails(...args),
}));
vi.mock("@/features/online-board/hooks/useOnlineBoard.js", () => ({
useOnlineBoard: () => ({
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("@/shared/hooks/useAppSettings.js", () => ({
useAppSettings: () => ({
onlineboardSearchFrom: 2,