URL surface parity with Angular for /popular and start-page prefill
- Drop the React-only standalone /popular route (and its e2e smoketest). Angular returns 404 for /ru-ru/popular; popular requests are surfaced inline on onlineboard/schedule start pages via PopularRequestsPanel (which stays). Matching the URL surface is a contractual requirement for the MF remote. - Replace ?tab/?departure/?arrival/?return query-string prefill on the onlineboard and schedule start pages with a sessionStorage transient slot. Mirrors Angular's OnlineBoardFiltersStateService / ScheduleFiltersStateService cross-page singletons: URLs stay clean of query strings, the start-page form still seeds itself from a popular-request click, and a fresh page reload (which bypasses the in-memory state in Angular) lands on a pristine form. Same-page popular clicks remount the filter via key bump so the useState initializers pick up the new prefill.
This commit is contained in:
@@ -9,16 +9,14 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { OnlineBoardStartPage, buildPopularRequestQueryParams } from "./OnlineBoardStartPage.js";
|
||||
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
|
||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
useSearchParams: () => [mockSearchParams],
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
@@ -65,88 +63,52 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: buildPopularRequestQueryParams
|
||||
// Pure function: buildOnlineBoardPrefillState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildPopularRequestQueryParams", () => {
|
||||
it("builds params for FlightNumber mode", () => {
|
||||
describe("buildOnlineBoardPrefillState", () => {
|
||||
it("builds state for FlightNumber mode", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "FlightNumber",
|
||||
carrier: "SU",
|
||||
flightNumber: "0654",
|
||||
type: "Onlineboard",
|
||||
};
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
expect(params.get("tab")).toBe("flight");
|
||||
expect(params.get("carrier")).toBe("SU");
|
||||
expect(params.get("flight")).toBe("0654");
|
||||
expect(params.has("departure")).toBe(false);
|
||||
expect(params.has("arrival")).toBe(false);
|
||||
expect(buildOnlineBoardPrefillState(request)).toEqual({
|
||||
tab: "flight",
|
||||
flightNumber: "SU0654",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds params for Departure mode", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "Departure",
|
||||
departure: "LED",
|
||||
type: "Onlineboard",
|
||||
};
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
expect(params.get("tab")).toBe("route");
|
||||
expect(params.get("departure")).toBe("LED");
|
||||
expect(params.has("arrival")).toBe(false);
|
||||
expect(params.has("carrier")).toBe(false);
|
||||
it("builds state for Departure mode", () => {
|
||||
expect(
|
||||
buildOnlineBoardPrefillState({
|
||||
mode: "Departure",
|
||||
departure: "LED",
|
||||
type: "Onlineboard",
|
||||
}),
|
||||
).toEqual({ tab: "route", departure: "LED" });
|
||||
});
|
||||
|
||||
it("builds params for Arrival mode", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "Arrival",
|
||||
arrival: "VKO",
|
||||
type: "Onlineboard",
|
||||
};
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
expect(params.get("tab")).toBe("route");
|
||||
expect(params.get("arrival")).toBe("VKO");
|
||||
expect(params.has("departure")).toBe(false);
|
||||
it("builds state for Arrival mode", () => {
|
||||
expect(
|
||||
buildOnlineBoardPrefillState({
|
||||
mode: "Arrival",
|
||||
arrival: "VKO",
|
||||
type: "Onlineboard",
|
||||
}),
|
||||
).toEqual({ tab: "route", arrival: "VKO" });
|
||||
});
|
||||
|
||||
it("builds params for Route mode (Onlineboard)", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "Route",
|
||||
departure: "LED",
|
||||
arrival: "KRR",
|
||||
type: "Onlineboard",
|
||||
};
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
expect(params.get("tab")).toBe("route");
|
||||
expect(params.get("departure")).toBe("LED");
|
||||
expect(params.get("arrival")).toBe("KRR");
|
||||
expect(params.has("return")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds params for Route mode (Schedule)", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "Route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
type: "Schedule",
|
||||
};
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
expect(params.get("departure")).toBe("SVO");
|
||||
expect(params.get("arrival")).toBe("LED");
|
||||
expect(params.has("return")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds params for RouteWithBack mode (Schedule) with return flag", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "RouteWithBack",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
type: "Schedule",
|
||||
};
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
expect(params.get("departure")).toBe("SVO");
|
||||
expect(params.get("arrival")).toBe("LED");
|
||||
expect(params.get("return")).toBe("true");
|
||||
it("builds state for Route mode", () => {
|
||||
expect(
|
||||
buildOnlineBoardPrefillState({
|
||||
mode: "Route",
|
||||
departure: "LED",
|
||||
arrival: "KRR",
|
||||
type: "Onlineboard",
|
||||
}),
|
||||
).toEqual({ tab: "route", departure: "LED", arrival: "KRR" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,14 +173,11 @@ describe("OnlineBoardStartPage", () => {
|
||||
expect(screen.getByTestId("feedback-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("navigates on popular request click (Onlineboard type)", () => {
|
||||
it("does not navigate on same-page popular click (Onlineboard type)", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
const btn = screen.getByTestId("popular-click");
|
||||
fireEvent.click(btn);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
search: expect.stringContaining("tab=flight"),
|
||||
}),
|
||||
);
|
||||
// Same-page click — the filter remounts via key bump, no nav.
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useMemo } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "@modern-js/runtime/router";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { useNavigate, useParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { PageLayout } from "@/ui/layout/PageLayout.js";
|
||||
import { PageTabs } from "@/ui/layout/PageTabs.js";
|
||||
@@ -21,47 +21,52 @@ import { SearchHistory } from "@/ui/layout/SearchHistory.js";
|
||||
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
|
||||
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
|
||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||
import {
|
||||
readAndClearTransientPrefill,
|
||||
writeTransientPrefill,
|
||||
} from "@/shared/state/transientPrefill.js";
|
||||
import "./OnlineBoardStartPage.scss";
|
||||
|
||||
/**
|
||||
* Build URL search params for a given popular request.
|
||||
*
|
||||
* Schedule-type requests produce params for the schedule page;
|
||||
* Onlineboard-type requests produce params for the online board filter.
|
||||
*/
|
||||
export function buildPopularRequestQueryParams(
|
||||
request: PopularRequest,
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
export const ONLINE_BOARD_PREFILL_SLOT = "online-board";
|
||||
export const SCHEDULE_PREFILL_SLOT = "schedule";
|
||||
|
||||
/**
|
||||
* Transient prefill state handed from a popular-request click to the
|
||||
* start page form. Mirrors Angular's `OnlineBoardFiltersStateService`
|
||||
* cross-page singleton: URLs stay clean of query strings while the
|
||||
* start-page form still seeds itself from the click. Held in
|
||||
* sessionStorage and read-then-cleared on mount, so a fresh tab or
|
||||
* page reload renders an empty form (matching Angular's behavior of a
|
||||
* pristine service after navigation).
|
||||
*/
|
||||
export interface OnlineBoardPrefillState {
|
||||
tab?: "flight" | "route";
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
flightNumber?: string;
|
||||
}
|
||||
|
||||
export function buildOnlineBoardPrefillState(
|
||||
request: PopularRequest,
|
||||
): OnlineBoardPrefillState {
|
||||
switch (request.mode) {
|
||||
case "FlightNumber":
|
||||
params.set("tab", "flight");
|
||||
params.set("carrier", request.carrier);
|
||||
params.set("flight", request.flightNumber);
|
||||
break;
|
||||
return {
|
||||
tab: "flight",
|
||||
flightNumber: `${request.carrier}${request.flightNumber}`,
|
||||
};
|
||||
case "Departure":
|
||||
params.set("tab", "route");
|
||||
params.set("departure", request.departure);
|
||||
break;
|
||||
return { tab: "route", departure: request.departure };
|
||||
case "Arrival":
|
||||
params.set("tab", "route");
|
||||
params.set("arrival", request.arrival);
|
||||
break;
|
||||
return { tab: "route", arrival: request.arrival };
|
||||
case "Route":
|
||||
case "RouteWithBack":
|
||||
if (request.type === "Onlineboard") {
|
||||
params.set("tab", "route");
|
||||
}
|
||||
params.set("departure", request.departure);
|
||||
params.set("arrival", request.arrival);
|
||||
if (request.mode === "RouteWithBack" && request.type === "Schedule") {
|
||||
params.set("return", "true");
|
||||
}
|
||||
break;
|
||||
return {
|
||||
tab: "route",
|
||||
departure: request.departure,
|
||||
arrival: request.arrival,
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export const OnlineBoardStartPage: FC = () => {
|
||||
@@ -69,53 +74,49 @@ export const OnlineBoardStartPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
/** Derive initial filter props from current URL query params */
|
||||
const filterInitialProps = useMemo(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
const departure = searchParams.get("departure");
|
||||
const arrival = searchParams.get("arrival");
|
||||
const carrier = searchParams.get("carrier");
|
||||
const flight = searchParams.get("flight");
|
||||
const date = searchParams.get("date");
|
||||
// Read-and-clear any prefill the previous page wrote. Stored in
|
||||
// useState (with a one-shot initializer) so React strict mode's
|
||||
// double-render doesn't lose the value on the second pass.
|
||||
const [prefill, setPrefill] = useState<OnlineBoardPrefillState>(
|
||||
() =>
|
||||
readAndClearTransientPrefill<OnlineBoardPrefillState>(
|
||||
ONLINE_BOARD_PREFILL_SLOT,
|
||||
) ?? {},
|
||||
);
|
||||
// Same-page popular clicks need to re-mount the filter so its
|
||||
// useState initial values pick up the new prefill. Key bump does it.
|
||||
const [filterKey, setFilterKey] = useState(0);
|
||||
|
||||
// No query params — use defaults
|
||||
if (!tab && !departure && !arrival && !carrier && !flight) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
...(tab === "flight" || tab === "route"
|
||||
? { initialTab: tab as "flight" | "route" }
|
||||
: {}),
|
||||
...(departure ? { initialDeparture: departure } : {}),
|
||||
...(arrival ? { initialArrival: arrival } : {}),
|
||||
...(date ? { initialDate: date } : {}),
|
||||
...(carrier && flight
|
||||
? { initialFlightNumber: `${carrier}${flight}` }
|
||||
: {}),
|
||||
};
|
||||
}, [searchParams]);
|
||||
const filterInitialProps = {
|
||||
...(prefill.tab ? { initialTab: prefill.tab } : {}),
|
||||
...(prefill.departure ? { initialDeparture: prefill.departure } : {}),
|
||||
...(prefill.arrival ? { initialArrival: prefill.arrival } : {}),
|
||||
...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}),
|
||||
};
|
||||
|
||||
const handlePopularRequestClick = useCallback(
|
||||
(request: PopularRequest) => {
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
|
||||
// Schedule-type requests navigate to the schedule feature
|
||||
if (request.type === "Schedule") {
|
||||
navigate({
|
||||
pathname: `/${lang}/schedule`,
|
||||
search: params.toString(),
|
||||
});
|
||||
const state =
|
||||
request.mode === "Route" || request.mode === "RouteWithBack"
|
||||
? {
|
||||
departure: request.departure,
|
||||
arrival: request.arrival,
|
||||
withReturn: request.mode === "RouteWithBack",
|
||||
}
|
||||
: {};
|
||||
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
|
||||
navigate(`/${lang}/schedule`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Onlineboard requests stay on this page with query params
|
||||
navigate({
|
||||
pathname: `/${lang}/onlineboard`,
|
||||
search: params.toString(),
|
||||
});
|
||||
// Onlineboard request — same page. Update local prefill +
|
||||
// remount the filter via key bump so its useState initializers
|
||||
// see the new initial* props.
|
||||
setPrefill(buildOnlineBoardPrefillState(request));
|
||||
setFilterKey((n) => n + 1);
|
||||
},
|
||||
[navigate, lang],
|
||||
);
|
||||
@@ -134,7 +135,7 @@ export const OnlineBoardStartPage: FC = () => {
|
||||
breadcrumbs={[]}
|
||||
contentLeft={
|
||||
<>
|
||||
<OnlineBoardFilter {...filterInitialProps} />
|
||||
<OnlineBoardFilter key={filterKey} {...filterInitialProps} />
|
||||
<SearchHistory />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ScheduleStartPage, buildSchedulePopularRequestQueryParams } from "./ScheduleStartPage.js";
|
||||
import { ScheduleStartPage } from "./ScheduleStartPage.js";
|
||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
let mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
useSearchParams: () => [mockSearchParams],
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
@@ -50,14 +48,14 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
|
||||
}));
|
||||
|
||||
vi.mock("@/features/online-board/components/OnlineBoardStartPage.js", () => ({
|
||||
buildPopularRequestQueryParams: (request: PopularRequest) => {
|
||||
const params = new URLSearchParams();
|
||||
buildOnlineBoardPrefillState: (request: PopularRequest) => {
|
||||
if (request.mode === "Departure") {
|
||||
params.set("tab", "route");
|
||||
params.set("departure", (request as { departure: string }).departure);
|
||||
return { tab: "route", departure: request.departure };
|
||||
}
|
||||
return params;
|
||||
return {};
|
||||
},
|
||||
ONLINE_BOARD_PREFILL_SLOT: "online-board",
|
||||
SCHEDULE_PREFILL_SLOT: "schedule",
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useCitySearch.js", () => ({
|
||||
@@ -68,56 +66,10 @@ vi.mock("@/ui/layout/SearchHistory.js", () => ({
|
||||
SearchHistory: () => <div data-testid="search-history" />,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: buildSchedulePopularRequestQueryParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildSchedulePopularRequestQueryParams", () => {
|
||||
it("builds params for Route mode with departure and arrival", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "Route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
type: "Schedule",
|
||||
};
|
||||
const params = buildSchedulePopularRequestQueryParams(request);
|
||||
expect(params.get("departure")).toBe("SVO");
|
||||
expect(params.get("arrival")).toBe("LED");
|
||||
expect(params.has("return")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds params for RouteWithBack mode with return flag", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "RouteWithBack",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
type: "Schedule",
|
||||
};
|
||||
const params = buildSchedulePopularRequestQueryParams(request);
|
||||
expect(params.get("departure")).toBe("SVO");
|
||||
expect(params.get("arrival")).toBe("LED");
|
||||
expect(params.get("return")).toBe("true");
|
||||
});
|
||||
|
||||
it("returns empty params for non-route modes", () => {
|
||||
const request: PopularRequest = {
|
||||
mode: "Arrival",
|
||||
arrival: "VKO",
|
||||
type: "Onlineboard",
|
||||
};
|
||||
const params = buildSchedulePopularRequestQueryParams(request);
|
||||
expect(params.toString()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ScheduleStartPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams = new URLSearchParams();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("renders the start page", () => {
|
||||
@@ -125,29 +77,29 @@ describe("ScheduleStartPage", () => {
|
||||
expect(screen.getByTestId("schedule-start")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("navigates to schedule page on Schedule-type popular request click", () => {
|
||||
it("writes prefill + navigates to schedule on Schedule-type popular click", () => {
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-route"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathname: "/ru/schedule",
|
||||
search: expect.stringContaining("departure=SVO"),
|
||||
}),
|
||||
expect(sessionStorage.getItem("afl-prefill:schedule")).toBe(
|
||||
JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: false }),
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru/schedule");
|
||||
});
|
||||
|
||||
it("navigates to onlineboard on Onlineboard-type popular request click", () => {
|
||||
it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => {
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-onlineboard"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathname: "/ru/onlineboard",
|
||||
}),
|
||||
expect(sessionStorage.getItem("afl-prefill:online-board")).toBe(
|
||||
JSON.stringify({ tab: "route", departure: "LED" }),
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru/onlineboard");
|
||||
});
|
||||
|
||||
it("initializes form from search params", () => {
|
||||
mockSearchParams = new URLSearchParams("departure=SVO&arrival=LED&return=true");
|
||||
it("initializes form from sessionStorage prefill", () => {
|
||||
sessionStorage.setItem(
|
||||
"afl-prefill:schedule",
|
||||
JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: true }),
|
||||
);
|
||||
render(<ScheduleStartPage />);
|
||||
const roundTripCheckbox = screen.getByTestId("round-trip-toggle");
|
||||
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { type FC, useState, useCallback, type FormEvent } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "@modern-js/runtime/router";
|
||||
import { useNavigate, useParams } from "@modern-js/runtime/router";
|
||||
import { Calendar } from "primereact/calendar";
|
||||
import { Slider, type SliderChangeEvent } from "primereact/slider";
|
||||
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
|
||||
@@ -19,7 +19,15 @@ import { PageTabs } from "@/ui/layout/PageTabs.js";
|
||||
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
|
||||
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
|
||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||
import { buildPopularRequestQueryParams } from "@/features/online-board/components/OnlineBoardStartPage.js";
|
||||
import {
|
||||
buildOnlineBoardPrefillState,
|
||||
ONLINE_BOARD_PREFILL_SLOT,
|
||||
SCHEDULE_PREFILL_SLOT,
|
||||
} from "@/features/online-board/components/OnlineBoardStartPage.js";
|
||||
import {
|
||||
readAndClearTransientPrefill,
|
||||
writeTransientPrefill,
|
||||
} from "@/shared/state/transientPrefill.js";
|
||||
import { buildScheduleUrl } from "../url.js";
|
||||
import "./ScheduleStartPage.scss";
|
||||
|
||||
@@ -43,23 +51,16 @@ function addDays(base: Date, days: number): Date {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL search params for a schedule-type popular request.
|
||||
*
|
||||
* Only Route and RouteWithBack modes produce params; other modes
|
||||
* return empty params (they belong to the online board).
|
||||
* Transient prefill state handed from a popular-request click via
|
||||
* sessionStorage. Mirrors Angular's `ScheduleFiltersStateService`
|
||||
* cross-page singleton: URL stays clean of query strings while the
|
||||
* form seeds from the click. Read-and-cleared on mount, so reload
|
||||
* gives a pristine form (matching Angular).
|
||||
*/
|
||||
export function buildSchedulePopularRequestQueryParams(
|
||||
request: PopularRequest,
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (request.mode === "Route" || request.mode === "RouteWithBack") {
|
||||
params.set("departure", request.departure);
|
||||
params.set("arrival", request.arrival);
|
||||
if (request.mode === "RouteWithBack") {
|
||||
params.set("return", "true");
|
||||
}
|
||||
}
|
||||
return params;
|
||||
export interface SchedulePrefillState {
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
withReturn?: boolean;
|
||||
}
|
||||
|
||||
export const ScheduleStartPage: FC = () => {
|
||||
@@ -67,17 +68,24 @@ export const ScheduleStartPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// One-shot read of any prefill the previous page wrote.
|
||||
const [prefill] = useState<SchedulePrefillState>(
|
||||
() =>
|
||||
readAndClearTransientPrefill<SchedulePrefillState>(
|
||||
SCHEDULE_PREFILL_SLOT,
|
||||
) ?? {},
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>(searchParams.get("departure") ?? "");
|
||||
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>(searchParams.get("arrival") ?? "");
|
||||
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>(prefill.departure ?? "");
|
||||
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>(prefill.arrival ?? "");
|
||||
const [dateFrom, setDateFrom] = useState<Date | null>(today);
|
||||
const [dateTo, setDateTo] = useState<Date | null>(addDays(today, 7));
|
||||
const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]);
|
||||
const [directOnly, setDirectOnly] = useState(false);
|
||||
const [isRoundTrip, setIsRoundTrip] = useState(searchParams.get("return") === "true");
|
||||
const [isRoundTrip, setIsRoundTrip] = useState(prefill.withReturn === true);
|
||||
const [returnDateFrom, setReturnDateFrom] = useState<Date | null>(addDays(today, 7));
|
||||
const [returnDateTo, setReturnDateTo] = useState<Date | null>(addDays(today, 14));
|
||||
const [returnTimeRange, setReturnTimeRange] = useState<[number, number]>([0, 1440]);
|
||||
@@ -149,20 +157,25 @@ export const ScheduleStartPage: FC = () => {
|
||||
const handlePopularRequestClick = useCallback(
|
||||
(request: PopularRequest) => {
|
||||
if (request.type === "Onlineboard") {
|
||||
const params = buildPopularRequestQueryParams(request);
|
||||
navigate({
|
||||
pathname: `/${lang}/onlineboard`,
|
||||
search: params.toString(),
|
||||
});
|
||||
writeTransientPrefill(
|
||||
ONLINE_BOARD_PREFILL_SLOT,
|
||||
buildOnlineBoardPrefillState(request),
|
||||
);
|
||||
navigate(`/${lang}/onlineboard`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule-type: build params and navigate to schedule with pre-fill
|
||||
const params = buildSchedulePopularRequestQueryParams(request);
|
||||
navigate({
|
||||
pathname: `/${lang}/schedule`,
|
||||
search: params.toString(),
|
||||
});
|
||||
// Schedule-type: only Route / RouteWithBack carry city info.
|
||||
const state: SchedulePrefillState =
|
||||
request.mode === "Route" || request.mode === "RouteWithBack"
|
||||
? {
|
||||
departure: request.departure,
|
||||
arrival: request.arrival,
|
||||
withReturn: request.mode === "RouteWithBack",
|
||||
}
|
||||
: {};
|
||||
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
|
||||
navigate(`/${lang}/schedule`);
|
||||
},
|
||||
[navigate, lang],
|
||||
);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Popular Requests standalone page route.
|
||||
*
|
||||
* Provides a standalone view of the PopularRequestsPanel. In Angular,
|
||||
* popular requests were embedded in OnlineBoard and Schedule start pages.
|
||||
* This route provides a direct-access path for the MF remote and allows
|
||||
* independent rendering/testing.
|
||||
*
|
||||
* URL: /{lang}/popular
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||
|
||||
const PopularRequestsPanel = lazy(() =>
|
||||
import("@/features/popular-requests/components/PopularRequestsPanel.js").then(
|
||||
(m) => ({ default: m.PopularRequestsPanel }),
|
||||
),
|
||||
);
|
||||
|
||||
export default function PopularPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRequestClick = useCallback(
|
||||
(request: PopularRequest) => {
|
||||
switch (request.mode) {
|
||||
case "FlightNumber":
|
||||
case "Arrival":
|
||||
case "Departure":
|
||||
void navigate(`/${lang}/onlineboard`);
|
||||
return;
|
||||
case "Route":
|
||||
if (request.type === "Onlineboard") {
|
||||
void navigate(`/${lang}/onlineboard`);
|
||||
} else {
|
||||
void navigate(`/${lang}/schedule`);
|
||||
}
|
||||
return;
|
||||
case "RouteWithBack":
|
||||
void navigate(`/${lang}/schedule`);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[lang, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div aria-busy="true">{t("SHARED.LOADING")}</div>}>
|
||||
<PopularRequestsPanel onRequestClick={handleRequestClick} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Cross-page transient prefill store.
|
||||
*
|
||||
* Mirrors Angular's `OnlineBoardFiltersStateService` /
|
||||
* `ScheduleFiltersStateService` — singleton state used to hand
|
||||
* popular-request data from one page to another without polluting the
|
||||
* URL with query strings. Reload clears it (matches Angular's behavior:
|
||||
* a fresh navigation to the page bypasses the service-held state).
|
||||
*
|
||||
* sessionStorage is the host: same-tab persistence, gone on tab close,
|
||||
* works around react-router v6's same-path navigation collapsing
|
||||
* `state` updates without re-rendering useLocation consumers.
|
||||
*/
|
||||
|
||||
const KEY_PREFIX = "afl-prefill:";
|
||||
|
||||
export function writeTransientPrefill<T>(slot: string, value: T): void {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
try {
|
||||
sessionStorage.setItem(KEY_PREFIX + slot, JSON.stringify(value));
|
||||
} catch {
|
||||
// ignore quota/disabled
|
||||
}
|
||||
}
|
||||
|
||||
export function readAndClearTransientPrefill<T>(slot: string): T | null {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(KEY_PREFIX + slot);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(KEY_PREFIX + slot);
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Popular Requests", () => {
|
||||
test("/ru/popular renders", async ({ page }) => {
|
||||
await page.goto("/ru/popular");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// The page should render without crashing. The PopularRequestsPanel
|
||||
// is lazy-loaded, so we wait for either the panel or the loading fallback.
|
||||
await expect(
|
||||
page.locator("main, [aria-busy='true'], body"),
|
||||
).not.toBeEmpty();
|
||||
|
||||
// URL should be correct
|
||||
expect(page.url()).toContain("/ru/popular");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ const navigateSpy = vi.fn();
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => navigateSpy,
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
useSearchParams: () => [new URLSearchParams()],
|
||||
useLocation: () => ({ state: null, pathname: "/ru/onlineboard" }),
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; [k: string]: unknown }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user