From 7f9ce8bf263b49bf3154902c282e6984c0fa360d Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 23 Apr 2026 14:52:28 +0300 Subject: [PATCH] Schedule list: row click navigates, no inline expand (TIRREDESIGN-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/? request=schedule-route-… and the per-row buy link is anchor-tagged to the SB booking URL on every visible row. --- .../components/DayGroupedFlightList.tsx | 201 ++++++++++-------- src/ui/flights/FlightList.tsx | 28 ++- tests/e2e/schedule-row-navigates.spec.ts | 57 +++++ 3 files changed, 185 insertions(+), 101 deletions(-) create mode 100644 tests/e2e/schedule-row-navigates.spec.ts diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index fed1e98c..3f835357 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -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(); 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 = ({ onFlightClick, buyUrlFor, initialCurrentFlightId, + dateFrom, + dateTo, + sortMode: controlledSortMode, + onSortChange, + hideColumnHeaders, }) => { const { language } = useLocale(); const { t } = useTranslation(); - const [sortMode, setSortMode] = useState("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>(() => new Set()); + const [internalSortMode, setInternalSortMode] = useState("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(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 | 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 = ({ {t("SCHEDULE.COL-FLIGHT") || "РЕЙС"} {t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"} + {/* Asterisk points to the `* Время в системе - МЕСТНОЕ.` footer note. */} {t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"} + * {sortBtn("departureUp", "up")} {sortBtn("departureDown", "down")} @@ -227,6 +249,7 @@ export const DayGroupedFlightList: FC = ({ {t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"} + * {sortBtn("arrivalUp", "up")} {sortBtn("arrivalDown", "down")} @@ -235,22 +258,11 @@ export const DayGroupedFlightList: FC = ({ ); - // 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 ( - 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 ; @@ -262,12 +274,11 @@ export const DayGroupedFlightList: FC = ({ if (groups.length === 1) { return (
- {headerRow} + {!hideColumnHeaders && headerRow} = ({ 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 (
- {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 (
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); } }} > - - {weekday.charAt(0).toUpperCase() + weekday.slice(1)} - -

{dayMonth}

- +
+ + {weekday.charAt(0).toUpperCase() + weekday.slice(1)} + +

{dayMonth}

+
+ {!isEmpty && ( + + )}
{!collapsed && ( = ({ 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 } : {})} diff --git a/tests/e2e/schedule-row-navigates.spec.ts b/tests/e2e/schedule-row-navigates.spec.ts new file mode 100644 index 00000000..d1dcb652 --- /dev/null +++ b/tests/e2e/schedule-row-navigates.spec.ts @@ -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/) +// • 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/?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/ + // 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}/, + ); + }); +});