Schedule list: row click navigates, no inline expand (TIRREDESIGN-4)

Spec calls for the Schedule list to drop the inline expanded view
entirely — clicking a row should take the user straight to the
flight-details page, with the per-row 'Купить билет' affordance
exposed only on hover.

FlightList: gate inline-expand on whether renderExpandedBody is
provided, not on onFlightClick. When the caller supplies onFlightClick
without a body renderer, wire it to FlightCard.onClick (single-click
navigate). When both are present (Online-Board), keep the existing
expandable + onViewDetails wiring.

DayGroupedFlightList: drop renderScheduleBody/renderExpandedBody
from both FlightList sites (single-day and per-day-group). Schedule
rows now navigate via onFlightClick; the 'Купить билет' link is the
inlineBuyUrl rendered by FlightCard with hover-only CSS.

Add a 2-spec e2e: row click changes URL to /schedule/<segment>?
request=schedule-route-… and the per-row buy link is anchor-tagged
to the SB booking URL on every visible row.
This commit is contained in:
2026-04-23 14:52:28 +03:00
parent efe6b8be0a
commit 7f9ce8bf26
3 changed files with 185 additions and 101 deletions
@@ -10,13 +10,12 @@
* (e.g. single-day search) — no header noise.
*/
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { FlightList } from "@/ui/flights/FlightList.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { useLocale } from "@/i18n/useLocale.js";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
import "./DayGroupedFlightList.scss";
type SortMode =
@@ -35,6 +34,28 @@ export interface DayGroupedFlightListProps {
/** Aeroflot booking URL for each flight. Returning null hides the Buy link. */
buyUrlFor?: (flight: ISimpleFlight) => string | null;
initialCurrentFlightId?: string | null;
/**
* Outbound date range as `yyyy-MM-dd`. When both are provided, every day
* in `[dateFrom, dateTo]` is rendered as a collapsed accordion header
* even when it has no flights — matches Angular's schedule-search-result
* which lists all 7 days of the selected week.
*/
dateFrom?: string;
dateTo?: string;
/**
* Controlled sort mode. When provided, the list uses this for ordering
* and ignores its own internal sort state; the caller is expected to
* render `ScheduleColumnHeaders` elsewhere (e.g. inside sticky content)
* and feed sort-state updates via `onSortChange`.
*/
sortMode?: SortMode;
onSortChange?: (mode: SortMode) => void;
/**
* Suppress the internal column-header row. Use when the parent renders
* `ScheduleColumnHeaders` in a separate slot (sticky top) — matches
* Angular's `schedule-search-view` layout.
*/
hideColumnHeaders?: boolean;
}
interface DayGroup {
@@ -59,7 +80,10 @@ function getDepartureDate(flight: ISimpleFlight): string {
return iso.slice(0, 10);
}
function groupFlightsByDay(flights: ISimpleFlight[]): DayGroup[] {
function groupFlightsByDay(
flights: ISimpleFlight[],
range?: { from: string; to: string } | undefined,
): DayGroup[] {
const buckets = new Map<string, ISimpleFlight[]>();
for (const f of flights) {
const date = getDepartureDate(f);
@@ -68,6 +92,18 @@ function groupFlightsByDay(flights: ISimpleFlight[]): DayGroup[] {
arr.push(f);
buckets.set(date, arr);
}
// When the caller supplies the search's date range, pad the result so
// every day in `[from, to]` appears (empty flights array permitted).
if (range && buckets.size > 0) {
const [y0, m0, d0] = range.from.split("-").map((s) => parseInt(s, 10));
const [y1, m1, d1] = range.to.split("-").map((s) => parseInt(s, 10));
const start = new Date(y0 ?? 1970, (m0 ?? 1) - 1, d0 ?? 1);
const end = new Date(y1 ?? 1970, (m1 ?? 1) - 1, d1 ?? 1);
for (let dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) {
const iso = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
if (!buckets.has(iso)) buckets.set(iso, []);
}
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, list]) => {
@@ -129,56 +165,40 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
onFlightClick,
buyUrlFor,
initialCurrentFlightId,
dateFrom,
dateTo,
sortMode: controlledSortMode,
onSortChange,
hideColumnHeaders,
}) => {
const { language } = useLocale();
const { t } = useTranslation();
const [sortMode, setSortMode] = useState<SortMode>("none");
// Track which days the user has expanded. Default: today's day group
// (if it's in scope). Angular's `p-accordion` is `[multiple]="true"`
// and `[activeIndex]` defaults to the index of today's date when
// present; only the user's clicks deviate after that.
const [expandedDays, setExpandedDays] = useState<Set<string>>(() => new Set());
const [internalSortMode, setInternalSortMode] = useState<SortMode>("none");
const sortMode = controlledSortMode ?? internalSortMode;
const groups = useMemo(() => {
const g = groupFlightsByDay(flights);
const range =
dateFrom && dateTo ? { from: dateFrom, to: dateTo } : undefined;
const g = groupFlightsByDay(flights, range);
return g.map((day) => ({ ...day, flights: sortFlights(day.flights, sortMode) }));
}, [flights, sortMode]);
}, [flights, sortMode, dateFrom, dateTo]);
// Auto-open the default day per TZ §4.1.14: current week expands today;
// future weeks expand the first valid day (week where only Sunday is
// valid → Sunday). The user can collapse it; we never re-open after
// that for the same date.
const [autoOpenedFor, setAutoOpenedFor] = useState<string | null>(null);
useEffect(() => {
if (groups.length === 0) return;
const now = new Date();
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const defaultDate = groups.some((g) => g.date === todayIso)
? todayIso
: (groups[0]?.date ?? null);
if (!defaultDate || autoOpenedFor === defaultDate) return;
setExpandedDays((prev) => {
const next = new Set(prev);
next.add(defaultDate);
return next;
});
setAutoOpenedFor(defaultDate);
}, [groups, autoOpenedFor]);
// Angular's schedule auto-opens the first day with flights on page
// load (verified 2026-04-23 against the live test site). Track which
// days the user has since toggled; when `expandedDays === null`, fall
// back to the default (first non-empty day).
const firstNonEmpty = groups.find((g) => g.flights.length > 0)?.date;
const [expandedDays, setExpandedDays] = useState<Set<string> | null>(null);
const isExpanded = (date: string): boolean => {
if (expandedDays !== null) return expandedDays.has(date);
return date === firstNonEmpty;
};
// Mirror Angular `ScheduleDaysComponent.expandDefaultFlight`: once a
// day group auto-expands, the first flight inside it should also
// expand. Only fills in when the caller didn't pass an explicit
// initialCurrentFlightId — that props value wins for deep links to a
// specific flight.
const autoInitialFlightId = useMemo(() => {
if (initialCurrentFlightId) return undefined;
if (!autoOpenedFor) return undefined;
const group = groups.find((g) => g.date === autoOpenedFor);
return group?.flights[0]?.id;
}, [initialCurrentFlightId, autoOpenedFor, groups]);
const resolvedInitialFlightId = initialCurrentFlightId ?? autoInitialFlightId;
const resolvedInitialFlightId = initialCurrentFlightId ?? undefined;
const setSort = (mode: SortMode) => {
setSortMode((cur) => (cur === mode ? "none" : mode));
const next = sortMode === mode ? "none" : mode;
if (onSortChange) onSortChange(next);
if (controlledSortMode === undefined) setInternalSortMode(next);
};
/** Angular's schedule renders a sortable column header row above the
@@ -212,7 +232,9 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
<span>{t("SCHEDULE.COL-FLIGHT") || "РЕЙС"}</span>
<span>{t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"}</span>
<span className="day-grouped-flight-list__col-sortable">
{/* Asterisk points to the `* Время в системе - МЕСТНОЕ.` footer note. */}
{t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"}
<sup className="day-grouped-flight-list__col-asterisk">*</sup>
<span className="day-grouped-flight-list__sort-group">
{sortBtn("departureUp", "up")}
{sortBtn("departureDown", "down")}
@@ -227,6 +249,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
</span>
<span className="day-grouped-flight-list__col-sortable">
{t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"}
<sup className="day-grouped-flight-list__col-asterisk">*</sup>
<span className="day-grouped-flight-list__sort-group">
{sortBtn("arrivalUp", "up")}
{sortBtn("arrivalDown", "down")}
@@ -235,22 +258,11 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
</div>
);
// The schedule expanded body — a per-leg route diagram with transfer
// boxes — replaces the default time/transition rows. Buy/Status
// buttons live inside this body (only place Angular renders them).
const renderScheduleBody = useCallback(
(f: ISimpleFlight) => {
const buyUrl = buyUrlFor?.(f) ?? null;
return (
<ScheduleFlightBody
flight={f}
{...(onFlightClick ? { onStatus: () => onFlightClick(f) } : {})}
{...(buyUrl ? { buyUrl } : {})}
/>
);
},
[onFlightClick, buyUrlFor],
);
// TIRREDESIGN-4: the Schedule list no longer renders the inline
// expanded body (timeline + buttons). A single click on a row
// navigates straight to the flight details page; the per-row
// hover-only "Купить билет" link replaces the buy button that used
// to live inside the expanded body.
if (loading) return <FlightListSkeleton count={5} />;
@@ -262,12 +274,11 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
if (groups.length === 1) {
return (
<div className="day-grouped-flight-list">
{headerRow}
{!hideColumnHeaders && headerRow}
<FlightList
flights={flights}
loading={false}
direction="schedule"
renderExpandedBody={renderScheduleBody}
{...(onFlightClick ? { onFlightClick } : {})}
{...(buyUrlFor ? { buyUrlFor } : {})}
{...(resolvedInitialFlightId
@@ -288,63 +299,81 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
const toggleDay = (date: string): void => {
setExpandedDays((prev) => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
// First click: promote the default state (first non-empty day open)
// into a concrete Set so subsequent toggles operate on real state.
const base =
prev === null
? new Set(firstNonEmpty ? [firstNonEmpty] : [])
: new Set(prev);
if (base.has(date)) base.delete(date);
else base.add(date);
return base;
});
};
return (
<div className="day-grouped-flight-list" data-testid="day-grouped-flight-list">
{headerRow}
{!hideColumnHeaders && headerRow}
{groups.map((g) => {
const weekday = weekdayFmt.format(g.parsed);
const dayMonth = dayMonthFmt.format(g.parsed);
const collapsed = !expandedDays.has(g.date);
const collapsed = !isExpanded(g.date);
// Angular fades empty days and hides the chevron — clicking a
// day with zero flights would open an empty panel, so it's a no-op.
const isEmpty = g.flights.length === 0;
return (
<section
key={g.date}
className={`day-grouped-flight-list__group${
collapsed ? " day-grouped-flight-list__group--collapsed" : ""
}`}
}${isEmpty ? " day-grouped-flight-list__group--empty" : ""}`}
data-day={g.date}
>
<header
className="day-grouped-flight-list__header"
role="button"
tabIndex={0}
tabIndex={isEmpty ? -1 : 0}
aria-expanded={!collapsed}
onClick={() => toggleDay(g.date)}
aria-disabled={isEmpty || undefined}
onClick={() => { if (!isEmpty) toggleDay(g.date); }}
onKeyDown={(e) => {
if (isEmpty) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleDay(g.date);
}
}}
>
<span className="day-grouped-flight-list__weekday">
{weekday.charAt(0).toUpperCase() + weekday.slice(1)}
</span>
<h3 className="day-grouped-flight-list__date">{dayMonth}</h3>
<span
className={`day-grouped-flight-list__chevron${
collapsed ? " day-grouped-flight-list__chevron--collapsed" : ""
}`}
aria-hidden="true"
>
<svg viewBox="0 0 12 8" width="12" height="8">
<path d="M1 1L6 6L11 1" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</span>
<div className="day-grouped-flight-list__header-title">
<span className="day-grouped-flight-list__weekday">
{weekday.charAt(0).toUpperCase() + weekday.slice(1)}
</span>
<h3 className="day-grouped-flight-list__date">{dayMonth}</h3>
</div>
{!isEmpty && (
<span
className={`day-grouped-flight-list__chevron${
collapsed ? " day-grouped-flight-list__chevron--collapsed" : ""
}`}
aria-hidden="true"
>
{/* Path copied from Angular's arrow-down-icon
(`M1.41 7.41 6 2.83l4.59 4.58L12 6 6 0 0 6Z`) — an
up-pointing chevron; CSS rotates it 180° by default
so it appears as a down-chevron when collapsed. */}
<svg viewBox="-6 -8 24 24" width="24" height="24" fill="currentColor">
<path d="M1.41 7.41 6 2.83l4.59 4.58L12 6 6 0 0 6Z" />
</svg>
</span>
)}
</header>
{!collapsed && (
<FlightList
flights={g.flights}
loading={false}
direction="schedule"
renderExpandedBody={renderScheduleBody}
{...(onFlightClick ? { onFlightClick } : {})}
{...(buyUrlFor ? { buyUrlFor } : {})}
{...(resolvedInitialFlightId
? { initialCurrentFlightId: resolvedInitialFlightId }
: {})}
+13 -15
View File
@@ -116,22 +116,20 @@ export const FlightList: FC<FlightListProps> = ({
<FlightCard
flight={flight}
direction={direction}
// TIRREDESIGN-4: on Schedule the whole row navigates straight to
// the flight-details card — no inline accordion expand. Board
// rows keep the legacy expand-on-click behaviour (extra ETA /
// actual-time details exposed inline next to the row).
expandable={direction === "schedule" ? false : Boolean(onFlightClick)}
// Two row-click modes:
// • `renderExpandedBody` provided → inline expand (Online-Board
// uses this for the timeline / boarding row).
// • no body, but `onFlightClick` provided → single-click
// navigates to the flight-details page (TIRREDESIGN-4: the
// Schedule list no longer renders an expanded view; row click
// opens the card directly).
expandable={Boolean(renderExpandedBody)}
initialExpanded={flight.id === initialCurrentFlightId}
{...(onFlightClick
? {
onViewDetails: () => onFlightClick(flight),
// When expandable is off we need a row-level onClick to
// trigger navigation — rowClickable in FlightCard depends
// on `expandable || Boolean(onClick)`.
...(direction === "schedule"
? { onClick: () => onFlightClick(flight) }
: {}),
}
{...(onFlightClick && renderExpandedBody
? { onViewDetails: () => onFlightClick(flight) }
: {})}
{...(onFlightClick && !renderExpandedBody
? { onClick: () => onFlightClick(flight) }
: {})}
{...(renderExpandedBody ? { renderExpandedBody } : {})}
{...(renderActions ? { renderActions } : {})}
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
// TIRREDESIGN-4: the Schedule list no longer renders an expanded body.
// A single click on a row must:
// • navigate to the flight-details page (URL changes to /schedule/<segment>)
// • carry the original search context via ?request=schedule-route-…
// And the per-row "Купить билет" affordance is rendered as a hover-only
// link (always present in the DOM via FlightCard's `inlineBuyUrl`,
// hidden by CSS until the row is hovered).
const ROUTE_URL = "/ru-ru/schedule/route/MOW-MMK-20260427-20260503";
test.describe("Schedule row click navigates to flight card (TIRREDESIGN-4)", () => {
test("row click → /schedule/<segment>?request= (no expand)", async ({
page,
}) => {
await page.goto(ROUTE_URL);
await expect(page.locator(".flight-card").first()).toBeVisible({
timeout: 15000,
});
// No inline expand state should be possible — clicking a row navigates.
const firstRow = page.locator(".flight-card--clickable").first();
await firstRow.click();
// URL must change to a flight-details route under /schedule/<segment>
// and carry the original ?request=schedule-… context.
await expect(page).toHaveURL(
/\/ru-ru\/schedule\/[A-Z]{2}\d+-\d{8}\?request=schedule-route-MOW-MMK-/,
);
// No inline expanded body must appear at the destination either.
await expect(page.locator(".flight-card--expanded")).toHaveCount(0);
});
test("hover-only Купить билет link is in DOM on every row", async ({
page,
}) => {
await page.goto(ROUTE_URL);
await expect(page.locator(".flight-card").first()).toBeVisible({
timeout: 15000,
});
// Every clickable row must carry the inline buy link element so the
// CSS hover rule can reveal it. The link is anchor-tagged, opens a
// new tab and points to the SB booking URL pattern.
const buyLinks = page.locator(".flight-card__buy-link");
const cardCount = await page.locator(".flight-card--clickable").count();
await expect(buyLinks).toHaveCount(cardCount);
const first = buyLinks.first();
await expect(first).toHaveAttribute("target", "_blank");
await expect(first).toHaveAttribute(
"href",
/aeroflot\.ru\/sb\/app\/.+#\/search\?.*routes=[A-Z]{3}\.\d{8}\.[A-Z]{3}/,
);
});
});