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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user