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:
@@ -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 }
|
||||
: {})}
|
||||
|
||||
@@ -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 } : {})}
|
||||
|
||||
@@ -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}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user