Restore buy/share/status strip in schedule search results body
Angular search-results page renders <flight-details-body-actions> →
<flight-actions> with NO overrides inside every expanded flight body —
share/buy/register/status all surface there. A prior refactor confused
this with the dedicated /schedule/details page, where Angular's
flight-schedule-details DOES set [share]=false [buy]=false [print]=false
[details]=false [register]=false because that page-level summary owns
those affordances. The strip was removed from both contexts, leaving
the search results page (e.g. /ru-ru/schedule/route/AER-LED-…) without
any buy button when a flight is expanded.
ScheduleFlightBody now accepts an opt-in showActions flag and renders
the existing <FlightActions> at the bottom (Angular-parity gating via
canBuyTicket / canViewFlightStatus). DayGroupedFlightList opts in;
ScheduleDetailsPage stays opted out so its page-level summary remains
the single owner of share/buy on the details page.
Note on e2e: tests/e2e/schedule-route-buy-button.spec.ts asserts the
button surfaces after expanding the first card, but the local dev
server's curl-based API proxy is currently being blocked by the
upstream WAF ("Доступ к сайту временно ограничен"), so the spec runs
green only against environments that reach /api. CI + deployed
verification suites cover that path. Behaviour is also locked in by:
- ScheduleFlightBody.test.tsx — strip renders iff showActions=true
- DayGroupedFlightList.test.tsx — passes showActions=true through
This commit is contained in:
@@ -29,7 +29,12 @@ vi.mock("@/shared/hooks/useDictionaries.js", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("./ScheduleFlightBody.js", () => ({
|
||||
ScheduleFlightBody: () => <div data-testid="schedule-flight-body" />,
|
||||
ScheduleFlightBody: ({ showActions }: { showActions?: boolean }) => (
|
||||
<div
|
||||
data-testid="schedule-flight-body"
|
||||
data-show-actions={showActions ? "true" : "false"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/ui/flights/IFlyWarning.js", () => ({ IFlyWarning: () => null }));
|
||||
vi.mock("@/features/online-board/components/BoardDetailsHeader/FlightEvents.js", () => ({
|
||||
@@ -240,6 +245,39 @@ describe("§4.1.14.3 Table 38 — Connecting flight collapsed row", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buy/share/status action strip — Angular's `<flight-details-body-actions>`
|
||||
// renders inside each expanded flight body on the search results page.
|
||||
// DayGroupedFlightList must pass `showActions={true}` so the strip appears.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schedule search results body — action strip", () => {
|
||||
// Same-day flights so DayGroupedFlightList takes the single-group
|
||||
// FlightList path (renderExpandedBody flows directly to FlightCard).
|
||||
const a = makeDirectFlight({
|
||||
depLocal: "2026-05-01T08:00:00+03:00",
|
||||
arrLocal: "2026-05-01T10:30:00+03:00",
|
||||
});
|
||||
const b = makeDirectFlight({
|
||||
depLocal: "2026-05-01T14:00:00+03:00",
|
||||
arrLocal: "2026-05-01T16:30:00+03:00",
|
||||
});
|
||||
const flights = [
|
||||
{ ...a, id: "f-a" },
|
||||
{ ...b, id: "f-b" },
|
||||
] as ISimpleFlight[];
|
||||
|
||||
it("renders the schedule body with showActions=true so the buy/share strip surfaces", () => {
|
||||
render(<DayGroupedFlightList flights={flights} />);
|
||||
// Expand the first card so the body mock renders.
|
||||
const row = document.querySelector(".flight-card__row") as HTMLElement;
|
||||
fireEvent.click(row);
|
||||
const body = screen.getAllByTestId("schedule-flight-body")[0];
|
||||
expect(body).toBeTruthy();
|
||||
expect(body?.getAttribute("data-show-actions")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §4.1.14.3 — DayGroupedFlightList grouping + day headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -134,7 +134,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
|
||||
onSortChange,
|
||||
hideColumnHeaders,
|
||||
}) => {
|
||||
const { language } = useLocale();
|
||||
const { locale, language } = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const [internalSortMode, setInternalSortMode] = useState<SortMode>("none");
|
||||
const sortMode = sortModeProp ?? internalSortMode;
|
||||
@@ -246,20 +246,15 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
|
||||
);
|
||||
|
||||
// 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).
|
||||
// boxes — replaces the default time/transition rows. Mirrors Angular's
|
||||
// `<flight-details-body-actions>` strip (share + buy + register + status)
|
||||
// via `showActions`. The collapsed-row inline buy link wired through
|
||||
// FlightList's `buyUrlFor` prop is independent of this strip.
|
||||
const renderScheduleBody = useCallback(
|
||||
(f: ISimpleFlight) => {
|
||||
const buyUrl = buyUrlFor?.(f) ?? null;
|
||||
return (
|
||||
<ScheduleFlightBody
|
||||
flight={f}
|
||||
{...(onFlightClick ? { onStatus: () => onFlightClick(f) } : {})}
|
||||
{...(buyUrl ? { buyUrl } : {})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onFlightClick, buyUrlFor],
|
||||
(f: ISimpleFlight) => (
|
||||
<ScheduleFlightBody flight={f} showActions locale={locale} />
|
||||
),
|
||||
[locale],
|
||||
);
|
||||
|
||||
if (loading) return <FlightListSkeleton count={5} />;
|
||||
|
||||
@@ -163,10 +163,17 @@
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: vars.$space-m;
|
||||
padding: vars.$space-l vars.$space-xl;
|
||||
border-top: 1px dashed colors.$border;
|
||||
background: colors.$white;
|
||||
|
||||
.flight-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: vars.$space-m;
|
||||
}
|
||||
}
|
||||
|
||||
&__spacer {
|
||||
|
||||
@@ -39,6 +39,12 @@ vi.mock("@/ui/flights/OperatorLogo.js", () => ({
|
||||
<span data-testid="operator-logo">{carrier}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/features/online-board/components/BoardDetailsHeader/FlightActions.js",
|
||||
() => ({
|
||||
FlightActions: () => <div data-testid="flight-actions" />,
|
||||
}),
|
||||
);
|
||||
|
||||
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
|
||||
|
||||
@@ -197,9 +203,27 @@ describe("ScheduleFlightBody – TZ §4.1.14.4", () => {
|
||||
expect(stations.some((s) => s.textContent === "LED")).toBe(true);
|
||||
});
|
||||
|
||||
// Share/Buy/Status no longer render inside the per-leg body — they
|
||||
// live in the page-level summary header (mirroring Angular's
|
||||
// `flight-schedule-details [share]=false [buy]=false [print]=false`).
|
||||
it("does NOT render the action strip when showActions is omitted", () => {
|
||||
// Schedule details page omits `showActions` because its page-level
|
||||
// summary header owns share/buy. Mirrors Angular's
|
||||
// `flight-schedule-details [share]=false [buy]=false [print]=false`.
|
||||
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
|
||||
expect(screen.queryByTestId("schedule-flight-body-actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the action strip when showActions is true", () => {
|
||||
// Search results page opts in to mirror Angular's
|
||||
// `<flight-details-body-actions>` → `<flight-actions>` (defaults all
|
||||
// true except print).
|
||||
render(
|
||||
<ScheduleFlightBody
|
||||
flight={makeDirectFlight(FUTURE_10D)}
|
||||
showActions
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("schedule-flight-body-actions")).toBeTruthy();
|
||||
expect(screen.getByTestId("flight-actions")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-leg flight structure", () => {
|
||||
|
||||
@@ -29,10 +29,21 @@ import { resolveCarrierByFlightNumber } from "@/shared/operatorIcon.js";
|
||||
import { TimeGroup } from "@/ui/flights/TimeGroup.js";
|
||||
import { StationDisplay } from "@/ui/flights/StationDisplay.js";
|
||||
import { OperatorLogo } from "@/ui/flights/OperatorLogo.js";
|
||||
import { FlightActions } from "@/features/online-board/components/BoardDetailsHeader/FlightActions.js";
|
||||
import "./ScheduleFlightBody.scss";
|
||||
|
||||
export interface ScheduleFlightBodyProps {
|
||||
flight: ISimpleFlight;
|
||||
/**
|
||||
* Render the share/buy/register/status action strip at the bottom of
|
||||
* the body — mirrors Angular's `flight-details-body-actions` on the
|
||||
* schedule search results page. Off by default so the schedule
|
||||
* details page (which owns its own page-level summary actions) stays
|
||||
* unchanged.
|
||||
*/
|
||||
showActions?: boolean;
|
||||
/** Locale used to build the buy-ticket URL when `showActions` is true. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface ChildFlightId {
|
||||
@@ -81,14 +92,10 @@ function transferDuration(prev: IFlightLeg, next: IFlightLeg): string {
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
|
||||
}
|
||||
|
||||
// ── TZ §4.1.14.4.4 – Buy-button visibility gate ─────────────────────────────
|
||||
// Visible when:
|
||||
// • Departure UTC is > 2 h from now (not about to depart / already departed)
|
||||
// • Departure UTC is < 330 days from now
|
||||
// Eligibility is assessed on the FIRST leg of the flight (TZ §4.1.14.4.4:
|
||||
// "рассчитывается исходя из времени первого сегмента").
|
||||
export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
|
||||
flight,
|
||||
showActions = false,
|
||||
locale = "ru-ru",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { language } = useLocale();
|
||||
@@ -367,12 +374,27 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Angular's `flight-schedule-details` renders `flight-actions`
|
||||
with `share=false buy=false print=false details=false
|
||||
register=false`, so the per-leg body has no action strip —
|
||||
share + buy live in the page-level summary header instead.
|
||||
Only the Status button can surface here, and only when the
|
||||
enclosing page is a connecting itinerary (`connected=true`). */}
|
||||
{/* Angular search-results expanded body renders
|
||||
`<flight-details-body-actions>` → `<flight-actions>` with all
|
||||
defaults (share+buy+register+status, no print). The schedule
|
||||
details page suppresses these (page-level summary owns them),
|
||||
so callers opt in via `showActions`. Buy/Status visibility is
|
||||
gated by `<FlightActions>` (TZ §4.1.14.4.4 / §4.1.14.4.5). */}
|
||||
{showActions && (
|
||||
<div
|
||||
className="schedule-flight-body__actions"
|
||||
data-testid="schedule-flight-body-actions"
|
||||
>
|
||||
<FlightActions
|
||||
flight={flight}
|
||||
locale={locale}
|
||||
showShare
|
||||
showBuy
|
||||
showRegister
|
||||
showStatus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from "./fixtures/console-gate";
|
||||
|
||||
// Schedule search-results page must mirror Angular's
|
||||
// `<flight-details-body-actions>` strip in each expanded flight body —
|
||||
// that's where Angular surfaces the buy ticket button (TZ §4.1.14.4.4).
|
||||
// The component had been silently dropped during a refactor that confused
|
||||
// the search-results page with the dedicated `flight-schedule-details`
|
||||
// page (which DOES suppress buy/share because its page-level summary
|
||||
// owns those affordances).
|
||||
|
||||
test("schedule route page surfaces the buy ticket button inside an expanded flight body", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503");
|
||||
|
||||
// Wait for flights to render. The schedule list builds rows of
|
||||
// .flight-card items inside DayGroupedFlightList.
|
||||
const firstCard = page.locator(".flight-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Expand the first flight by clicking its row. FlightCard wires
|
||||
// expandable=true on the schedule path so a click toggles the body.
|
||||
await firstCard.click();
|
||||
|
||||
// The action strip (`schedule-flight-body-actions`) wraps the
|
||||
// restored `<FlightActions>` row.
|
||||
const actions = page
|
||||
.locator('[data-testid="schedule-flight-body-actions"]')
|
||||
.first();
|
||||
await expect(actions).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The buy ticket button (FlightActions → BuyTicketButton) must be
|
||||
// present whenever the flight is in the [now+2h, now+330d] window.
|
||||
// Today + few days is squarely inside that window for typical flights.
|
||||
await expect(
|
||||
actions.locator('[data-testid="buy-ticket-button"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user