Add TZ Table 7 mode-specific leaf breadcrumb + clickable back-to-search link on Online-Board details
Per TZ §4.1.4 Table 7 rows 6–8, the details page now builds a 3-item
breadcrumb trail [Home, Онлайн-Табло, <leaf>] where <leaf> is mode-aware:
- flight: "Номер рейса: SU-1234" (hyphen per TZ)
- departure: "Вылет: {station}"
- arrival: "Прилет: {station}"
- route: "Маршрут: {dep}-{arr}"
The leaf crumb is a clickable link back to the source search page with
time-range preserved in the URL. Share-link entries (no ?request=) get
only [Home, Онлайн-Табло] with no leaf.
Breadcrumbs component updated to allow last-item links (was suppressed),
since TZ explicitly requires the leaf to be navigatable.
CONFLICT (deferred to Task 16): TZ row 6 says departure/arrival leaf
should show both dep+arr cities; parentRequest only carries one station,
so only that station is shown. Departure/arrival search returns flights
to many arrival cities — "both cities" makes no sense at search-mode level.
This commit is contained in:
@@ -387,6 +387,125 @@ describe("OnlineBoardDetailsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TZ §4.1.4 Table 7 breadcrumb leaf (rows 6–8)", () => {
|
||||
/**
|
||||
* Helper: return all <a> elements inside the breadcrumbs nav whose
|
||||
* text content matches the given string.
|
||||
*/
|
||||
function getCrumbLink(container: HTMLElement, text: string): HTMLAnchorElement | null {
|
||||
const nav = container.querySelector("[data-testid='breadcrumbs']");
|
||||
if (!nav) return null;
|
||||
const anchors = Array.from(nav.querySelectorAll("a"));
|
||||
return (anchors.find((a) => a.textContent?.trim() === text) as HTMLAnchorElement) ?? null;
|
||||
}
|
||||
|
||||
it("4.1.4-R-Crumb-flight: flight-mode details shows 'BREADCRUMBS.FLIGHT-NUMBER' as last clickable crumb pointing to flight search URL", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams(
|
||||
"request=onlineboard-flight-SU1234-20260515",
|
||||
);
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
// t mock returns key; leaf label key is BREADCRUMBS.FLIGHT-NUMBER
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.FLIGHT-NUMBER");
|
||||
expect(link).toBeTruthy();
|
||||
// back URL: /ru/onlineboard/flight/SU1234-20260515
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/");
|
||||
expect(link?.getAttribute("href")).toContain("20260515");
|
||||
});
|
||||
|
||||
it("4.1.4-R-Crumb-route: route-mode details shows 'BREADCRUMBS.ROUTE' as last clickable crumb pointing to route search URL", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams(
|
||||
"request=onlineboard-route-MOW-LED-20260515",
|
||||
);
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.ROUTE");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/route/MOW-LED-20260515");
|
||||
});
|
||||
|
||||
it("4.1.4-R-Crumb-departure: departure-mode details shows 'BREADCRUMBS.DEPARTURE' as last clickable crumb pointing to departure search URL", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams(
|
||||
"request=onlineboard-departure-SVO-20260515",
|
||||
);
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.DEPARTURE");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/departure/SVO-20260515");
|
||||
});
|
||||
|
||||
it("4.1.4-R-Crumb-arrival: arrival-mode details shows 'BREADCRUMBS.ARRIVAL' as last clickable crumb pointing to arrival search URL", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams(
|
||||
"request=onlineboard-arrival-SVO-20260515",
|
||||
);
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.ARRIVAL");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/arrival/SVO-20260515");
|
||||
});
|
||||
|
||||
it("4.1.4-R-Crumb-share-link: details opened without ?request= (share link) shows only [Home, Онлайн-Табло] with no leaf", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams();
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const nav = container.querySelector("[data-testid='breadcrumbs']");
|
||||
expect(nav).toBeTruthy();
|
||||
const items = nav!.querySelectorAll(".breadcrumbs__item");
|
||||
// Home + BOARD.TITLE = 2 items, no leaf
|
||||
expect(items.length).toBe(2);
|
||||
// The leaf keys must NOT appear
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.FLIGHT-NUMBER");
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.ROUTE");
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.DEPARTURE");
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.ARRIVAL");
|
||||
});
|
||||
|
||||
it("4.1.4-R-Crumb-timerange: details opened from route with time range → back crumb URL includes time range suffix", () => {
|
||||
// onlineboard-route-MOW-LED-20260515-09001800 (timeFrom=0900 timeTo=1800)
|
||||
mockSearchParamsInstance = new URLSearchParams(
|
||||
"request=onlineboard-route-MOW-LED-20260515-09001800",
|
||||
);
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.ROUTE");
|
||||
expect(link).toBeTruthy();
|
||||
// URL should contain the time range suffix 09001800
|
||||
expect(link?.getAttribute("href")).toContain("09001800");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parent-request codec (TZ §4.1.2 Table 5 row 6)", () => {
|
||||
it("4.1.2-R-Request-route: hydrates mini-list from route parent-request", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams(
|
||||
|
||||
@@ -414,6 +414,75 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
return raw ? parseDetailsRequestParam(raw) : null;
|
||||
}, [searchParams]);
|
||||
|
||||
// TZ §4.1.4 Table 7 rows 6–8: build mode-specific leaf breadcrumb.
|
||||
// The leaf is clickable and navigates back to the source search page
|
||||
// with filter state (including time range) preserved.
|
||||
//
|
||||
// CONFLICT (Task 16 arbitration pending): TZ row 6 says departure/arrival
|
||||
// leaf should show "{depCity}-{arrCity}" but parentRequest only carries one
|
||||
// city (the station). We show only that city. See AGENTS.md Conflicts register.
|
||||
const detailsCrumbs = useMemo(() => {
|
||||
const baseCrumbs = [{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }];
|
||||
if (!parentRequest) return baseCrumbs;
|
||||
|
||||
const backUrl = (() => {
|
||||
switch (parentRequest.kind) {
|
||||
case "flight": {
|
||||
// flightNumber in the request param is carrier+number (e.g. "SU1234").
|
||||
// Split into carrier and number for buildOnlineBoardUrl.
|
||||
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
|
||||
if (!m || !m[1] || !m[2]) return `/${locale}/onlineboard`;
|
||||
return `/${locale}/${buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: m[1],
|
||||
flightNumber: m[2],
|
||||
date: parentRequest.date,
|
||||
})}`;
|
||||
}
|
||||
case "departure":
|
||||
return `/${locale}/${buildOnlineBoardUrl(
|
||||
parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { type: "departure", station: parentRequest.station, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: { type: "departure", station: parentRequest.station, date: parentRequest.date },
|
||||
)}`;
|
||||
case "arrival":
|
||||
return `/${locale}/${buildOnlineBoardUrl(
|
||||
parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { type: "arrival", station: parentRequest.station, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: { type: "arrival", station: parentRequest.station, date: parentRequest.date },
|
||||
)}`;
|
||||
case "route":
|
||||
return `/${locale}/${buildOnlineBoardUrl(
|
||||
parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { type: "route", departure: parentRequest.departure, arrival: parentRequest.arrival, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: { type: "route", departure: parentRequest.departure, arrival: parentRequest.arrival, date: parentRequest.date },
|
||||
)}`;
|
||||
}
|
||||
})();
|
||||
|
||||
const leafLabel = (() => {
|
||||
switch (parentRequest.kind) {
|
||||
case "flight": {
|
||||
// Format flight number with hyphen per TZ Table 7: "SU-1234"
|
||||
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
|
||||
const formatted = m?.[1] && m?.[2] ? `${m[1]}-${m[2]}` : parentRequest.flightNumber;
|
||||
return t("BREADCRUMBS.FLIGHT-NUMBER", { flightNumber: formatted });
|
||||
}
|
||||
case "departure":
|
||||
return t("BREADCRUMBS.DEPARTURE", { city: parentRequest.station });
|
||||
case "arrival":
|
||||
return t("BREADCRUMBS.ARRIVAL", { city: parentRequest.station });
|
||||
case "route":
|
||||
return t("BREADCRUMBS.ROUTE", {
|
||||
departureCity: parentRequest.departure,
|
||||
arrivalCity: parentRequest.arrival,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return [...baseCrumbs, { label: leafLabel, url: backUrl }];
|
||||
}, [parentRequest, locale, t]);
|
||||
|
||||
const parentParams = useMemo(() => {
|
||||
if (!parentRequest) return null;
|
||||
const d = parentRequest.date;
|
||||
@@ -464,13 +533,9 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
[flightId.carrier, flightId.flightNumber, flightId.suffix, locale, navigate],
|
||||
);
|
||||
|
||||
const onlineboardHref = `/${locale}/onlineboard`;
|
||||
// Angular uses the 'Онлайн-Табло' title (BOARD.TITLE) as the sole
|
||||
// breadcrumb leaf — the flight number itself is NOT a breadcrumb entry.
|
||||
const rootBreadcrumbLabel = t("BOARD.TITLE");
|
||||
const commonLayoutProps = {
|
||||
headerLeft: <DetailsBackButton locale={locale} />,
|
||||
breadcrumbs: [{ label: rootBreadcrumbLabel, url: onlineboardHref }],
|
||||
breadcrumbs: detailsCrumbs,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -531,7 +596,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
<PageLayout
|
||||
headerLeft={<DetailsBackButton locale={locale} />}
|
||||
title={<h1 className="flight-details__flight-number">{pageTitle}</h1>}
|
||||
breadcrumbs={[{ label: rootBreadcrumbLabel, url: onlineboardHref }]}
|
||||
breadcrumbs={detailsCrumbs}
|
||||
contentLeft={
|
||||
<FlightsMiniList
|
||||
flights={miniListFlights}
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "Ticket kaufen",
|
||||
"CONNECTING_FLIGHTS": "Anschlussflüge anzeigen",
|
||||
"NO_DIRECTIONS_INFO": "Es wurden keine Routen gefunden, ändern Sie die Sucheinstellungen"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,11 @@
|
||||
"OPERATED-BY": "Operated by"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "Online Board"
|
||||
"ONLINEBOARD": "Online Board",
|
||||
"FLIGHT-NUMBER": "Flight: {flightNumber}",
|
||||
"DEPARTURE": "Departure: {city}",
|
||||
"ARRIVAL": "Arrival: {city}",
|
||||
"ROUTE": "Route: {departureCity}-{arrivalCity}"
|
||||
},
|
||||
"DETAILS": {
|
||||
"REGISTRATION": "Check-in",
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "Comprar billete",
|
||||
"CONNECTING_FLIGHTS": "Mostrar vuelos de conexión",
|
||||
"NO_DIRECTIONS_INFO": "No se encontraron rutas, cambie los parámetros de búsqueda"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "Acheter un billet",
|
||||
"CONNECTING_FLIGHTS": "Afficher les vols de correspondance",
|
||||
"NO_DIRECTIONS_INFO": "Aucun itinéraire trouvé, modifiez les paramètres de recherche"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "Acquista il biglietto",
|
||||
"CONNECTING_FLIGHTS": "Mostra i voli in coincidenza",
|
||||
"NO_DIRECTIONS_INFO": "Nessun percorso trovato, modificare le opzioni di ricerca"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "チケットを購入",
|
||||
"CONNECTING_FLIGHTS": "乗り継ぎ便の表示",
|
||||
"NO_DIRECTIONS_INFO": "ルートが見つかりません、検索パラメータを変更します"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "항공권 구매",
|
||||
"CONNECTING_FLIGHTS": "연결 항공편 표시",
|
||||
"NO_DIRECTIONS_INFO": "경로를 찾을 수 없음,검색 매개 변수 변경"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,11 @@
|
||||
"OPERATED-BY": "Выполняет рейс"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "Онлайн-табло"
|
||||
"ONLINEBOARD": "Онлайн-табло",
|
||||
"FLIGHT-NUMBER": "Номер рейса: {flightNumber}",
|
||||
"DEPARTURE": "Вылет: {city}",
|
||||
"ARRIVAL": "Прилет: {city}",
|
||||
"ROUTE": "Маршрут: {departureCity}-{arrivalCity}"
|
||||
},
|
||||
"DETAILS": {
|
||||
"REGISTRATION": "Регистрация",
|
||||
|
||||
@@ -412,5 +412,12 @@
|
||||
"BUY_TICKET_BTN": "购买机票",
|
||||
"CONNECTING_FLIGHTS": "显示联程航班",
|
||||
"NO_DIRECTIONS_INFO": "没有找到路线,更改搜索参数"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "",
|
||||
"FLIGHT-NUMBER": "",
|
||||
"DEPARTURE": "",
|
||||
"ARRIVAL": "",
|
||||
"ROUTE": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,13 @@ export const Breadcrumbs: FC<BreadcrumbsProps> = ({ items = [] }) => {
|
||||
<ol className="breadcrumbs__list">
|
||||
{allItems.map((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
// The Home crumb (index 0) is always a link, even on start pages
|
||||
// where it happens to be the only/last item — matches Angular,
|
||||
// which always renders 'Главная' with the aeroflot.ru href.
|
||||
const showAsLink = Boolean(item.url) && (index === 0 || !isLast);
|
||||
// All items with a url render as links. This includes the last item
|
||||
// (leaf crumb) when it represents a navigatable back-link, per TZ
|
||||
// Table 7 row 6 note: the last breadcrumb on details pages must
|
||||
// navigate back to the search results page with filter state preserved.
|
||||
// Previously this was `index === 0 || !isLast`, which suppressed links
|
||||
// on the last crumb — that was incorrect for details pages.
|
||||
const showAsLink = Boolean(item.url);
|
||||
return (
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
|
||||
Reference in New Issue
Block a user