Search execution, cancellation, and error handling per TZ §4.1.10/11/12
- AbortController wired through ApiClient → api functions → hooks so a new search immediately aborts the previous in-flight request (§4.1.12) - cancel() exposed from useOnlineBoard / useScheduleSearch; Escape key triggers it while the loader is showing (§4.1.12) - «Отменить поиск» button rendered during loading; hides when idle (§4.1.12) - data-searching attribute on search pages disables filter/tabs/breadcrumbs via pointer-events:none CSS while a search is running (§4.1.10/11) - Submit buttons disabled for 30 s after each search (hardcoded, per TZ §4.1.10/11: «не должно выноситься в конфигурационный файл») - Per-status error messages: BOARD.ERROR-TIMEOUT / ERROR-4XX / ERROR-5XX replace the generic LOAD-FAILED-MESSAGE (§4.1.10.1/11.1) - Error messages added to all 9 locales - 8 new tests: 3 for AbortController wiring, 5 for error banners + cancel button visibility
This commit is contained in:
@@ -66,6 +66,7 @@ export interface CalendarParams {
|
|||||||
export async function searchFlights(
|
export async function searchFlights(
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
params: SearchFlightsParams,
|
params: SearchFlightsParams,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<IBoardResponse> {
|
): Promise<IBoardResponse> {
|
||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
dateFrom: params.dateFrom,
|
dateFrom: params.dateFrom,
|
||||||
@@ -78,7 +79,7 @@ export async function searchFlights(
|
|||||||
if (params.timeFrom) query["timeFrom"] = params.timeFrom;
|
if (params.timeFrom) query["timeFrom"] = params.timeFrom;
|
||||||
if (params.timeTo) query["timeTo"] = params.timeTo;
|
if (params.timeTo) query["timeTo"] = params.timeTo;
|
||||||
|
|
||||||
return client.get<IBoardResponse>(`flights/v1.1/${client.locale}/board`, query);
|
return client.get<IBoardResponse>(`flights/v1.1/${client.locale}/board`, query, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react";
|
import { type FC, useState, useCallback, useEffect, useRef, type FormEvent, useMemo } from "react";
|
||||||
import { useNavigate } from "@modern-js/runtime/router";
|
import { useNavigate } from "@modern-js/runtime/router";
|
||||||
import { useLocale } from "@/i18n/useLocale.js";
|
import { useLocale } from "@/i18n/useLocale.js";
|
||||||
import { Calendar } from "primereact/calendar";
|
import { Calendar } from "primereact/calendar";
|
||||||
@@ -150,6 +150,23 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
const boardMinDate = useRef(getBoardMinDate()).current;
|
const boardMinDate = useRef(getBoardMinDate()).current;
|
||||||
const boardMaxDate = useRef(getBoardMaxDate()).current;
|
const boardMaxDate = useRef(getBoardMaxDate()).current;
|
||||||
|
|
||||||
|
// §4.1.10 — submit button locked for 30 seconds after each search.
|
||||||
|
// Value is the timestamp when the lock expires (or 0 if unlocked).
|
||||||
|
// The 30-second constant is intentionally hardcoded (not configurable).
|
||||||
|
const [submitLockedUntil, setSubmitLockedUntil] = useState(0);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
// Tick every second while the lock is active so the disabled state
|
||||||
|
// updates reactively.
|
||||||
|
useEffect(() => {
|
||||||
|
if (submitLockedUntil === 0 || now >= submitLockedUntil) return;
|
||||||
|
const id = setTimeout(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [submitLockedUntil, now]);
|
||||||
|
const isSubmitLocked = useMemo(
|
||||||
|
() => submitLockedUntil > 0 && now < submitLockedUntil,
|
||||||
|
[submitLockedUntil, now],
|
||||||
|
);
|
||||||
|
|
||||||
// Swap the Calendar input's display text to "Сегодня" / "Завтра" per
|
// Swap the Calendar input's display text to "Сегодня" / "Завтра" per
|
||||||
// TZ §4.1.9 Tables 11+12 — Angular ships this and the raw 'DD.MM.YYYY'
|
// TZ §4.1.9 Tables 11+12 — Angular ships this and the raw 'DD.MM.YYYY'
|
||||||
// reads clinical in comparison.
|
// reads clinical in comparison.
|
||||||
@@ -227,6 +244,7 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
const handleFlightSubmit = useCallback(
|
const handleFlightSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isSubmitLocked) return;
|
||||||
|
|
||||||
const error = validateFlightNumber(flightNumber);
|
const error = validateFlightNumber(flightNumber);
|
||||||
setFlightNumberError(error);
|
setFlightNumberError(error);
|
||||||
@@ -250,15 +268,20 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
searchExecuted: true,
|
searchExecuted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable)
|
||||||
|
setSubmitLockedUntil(Date.now() + 30_000);
|
||||||
|
setNow(Date.now());
|
||||||
|
|
||||||
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
|
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
|
||||||
void navigate(`/${locale}/${url}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[flightNumber, flightDate, navigate, locale],
|
[flightNumber, flightDate, navigate, locale, isSubmitLocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRouteSubmit = useCallback(
|
const handleRouteSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isSubmitLocked) return;
|
||||||
|
|
||||||
// Mirrors Angular's OnlineBoardFilterService.toRoutePage + the
|
// Mirrors Angular's OnlineBoardFilterService.toRoutePage + the
|
||||||
// UrlBuilder.getRoutePageUrl switch: one-sided searches (only
|
// UrlBuilder.getRoutePageUrl switch: one-sided searches (only
|
||||||
@@ -324,9 +347,12 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
});
|
});
|
||||||
url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras });
|
url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras });
|
||||||
}
|
}
|
||||||
|
// Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable)
|
||||||
|
setSubmitLockedUntil(Date.now() + 30_000);
|
||||||
|
setNow(Date.now());
|
||||||
void navigate(`/${locale}/${url}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale],
|
[routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale, isSubmitLocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -452,6 +478,8 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="search-button"
|
className="search-button"
|
||||||
data-testid="search-submit"
|
data-testid="search-submit"
|
||||||
|
disabled={isSubmitLocked}
|
||||||
|
aria-disabled={isSubmitLocked}
|
||||||
>
|
>
|
||||||
<span>{t("SHARED.SEARCH")}</span>
|
<span>{t("SHARED.SEARCH")}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -622,6 +650,8 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="search-button"
|
className="search-button"
|
||||||
data-testid="search-submit"
|
data-testid="search-submit"
|
||||||
|
disabled={isSubmitLocked}
|
||||||
|
aria-disabled={isSubmitLocked}
|
||||||
>
|
>
|
||||||
<span>{t("SHARED.SEARCH")}</span>
|
<span>{t("SHARED.SEARCH")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Tests for per-status error messages in OnlineBoardSearchPage — TZ §4.1.10.1 / §4.1.12
|
||||||
|
*
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { OnlineBoardSearchPageProps } from "./OnlineBoardSearchPage.js";
|
||||||
|
import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js";
|
||||||
|
import type { UseOnlineBoardResult } from "../hooks/useOnlineBoard.js";
|
||||||
|
|
||||||
|
// ── shared mocks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock("@/i18n/provider.js", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: "ru" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@modern-js/runtime/router", () => ({
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
useParams: () => ({ lang: "ru-ru" }),
|
||||||
|
Link: ({ children, ...props }: Record<string, unknown>) =>
|
||||||
|
<a {...props}>{children as React.ReactNode}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/ui/layout/PageTabs.js", () => ({
|
||||||
|
PageTabs: () => <div data-testid="page-tabs" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./OnlineBoardFilter.js", () => ({
|
||||||
|
OnlineBoardFilter: () => <div data-testid="online-board-filter" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
|
||||||
|
useSearchHistory: () => ({ items: [], add: vi.fn(), clear: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
|
||||||
|
useFeatureFlag: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
|
||||||
|
useLiveBoardSearch: (_params: unknown, initialFlights: unknown) => ({
|
||||||
|
flights: initialFlights,
|
||||||
|
connectionStatus: "idle" as const,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useCalendarDays.js", () => ({
|
||||||
|
useCalendarDays: () => ({ days: [], loading: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/dictionaries/index.js", () => ({
|
||||||
|
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Hoisted mock for useOnlineBoard — overridden per test
|
||||||
|
const mockUseOnlineBoard = vi.fn<() => UseOnlineBoardResult>();
|
||||||
|
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
||||||
|
useOnlineBoard: () => mockUseOnlineBoard(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEPARTURE_PARAMS: OnlineBoardSearchPageProps["params"] = {
|
||||||
|
type: "departure",
|
||||||
|
station: "SVO",
|
||||||
|
date: "20260115",
|
||||||
|
};
|
||||||
|
|
||||||
|
function noResult(): UseOnlineBoardResult {
|
||||||
|
return {
|
||||||
|
flights: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("OnlineBoardSearchPage — §4.1.10.1 per-status error messages", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows BOARD.ERROR-TIMEOUT for ApiTimeoutError", async () => {
|
||||||
|
// Dynamic import so the mock is already in place
|
||||||
|
const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js");
|
||||||
|
|
||||||
|
mockUseOnlineBoard.mockReturnValue({
|
||||||
|
...noResult(),
|
||||||
|
error: new ApiTimeoutError(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||||
|
expect(screen.getByTestId("search-error")).toBeTruthy();
|
||||||
|
expect(screen.getByText("BOARD.ERROR-TIMEOUT")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows BOARD.ERROR-5XX for ApiHttpError with status 5xx", async () => {
|
||||||
|
const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js");
|
||||||
|
|
||||||
|
mockUseOnlineBoard.mockReturnValue({
|
||||||
|
...noResult(),
|
||||||
|
error: new ApiHttpError("Server error", 503),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||||
|
expect(screen.getByText("BOARD.ERROR-5XX")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows BOARD.ERROR-4XX for ApiHttpError with status 4xx", async () => {
|
||||||
|
const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js");
|
||||||
|
|
||||||
|
mockUseOnlineBoard.mockReturnValue({
|
||||||
|
...noResult(),
|
||||||
|
error: new ApiHttpError("Not found", 404),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||||
|
expect(screen.getByText("BOARD.ERROR-4XX")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows cancel button while loading (§4.1.12)", async () => {
|
||||||
|
const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js");
|
||||||
|
|
||||||
|
mockUseOnlineBoard.mockReturnValue({
|
||||||
|
...noResult(),
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||||
|
expect(screen.getByTestId("cancel-search-btn")).toBeTruthy();
|
||||||
|
expect(screen.getByText("SHARED.SEARCH-CANCEL")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides cancel button when not loading (§4.1.12)", async () => {
|
||||||
|
const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js");
|
||||||
|
|
||||||
|
mockUseOnlineBoard.mockReturnValue(noResult());
|
||||||
|
|
||||||
|
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||||
|
expect(screen.queryByTestId("cancel-search-btn")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,41 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §4.1.12 — loader bar with cancel button, shown while search is running
|
||||||
|
&__loader-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: vars.$space-m vars.$space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cancel-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: vars.$space-s2 vars.$space-xl;
|
||||||
|
background-color: colors.$blue-light;
|
||||||
|
color: colors.$white;
|
||||||
|
border: none;
|
||||||
|
border-radius: vars.$border-radius;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: fonts.$font-size-m;
|
||||||
|
font-weight: fonts.$font-medium;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: colors.$blue-light--hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.1.10 — while search is running, block interactions with filter,
|
||||||
|
// tabs, and breadcrumbs so the user cannot start a conflicting search.
|
||||||
|
&[data-searching="true"] {
|
||||||
|
.page-layout__left,
|
||||||
|
.page-layout__sticky,
|
||||||
|
.page-layout__breadcrumbs {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors Angular `page-footer-notes` under `pages/schedule/home.scss`:
|
// Mirrors Angular `page-footer-notes` under `pages/schedule/home.scss`:
|
||||||
// a blue-extra-light card attached to the bottom of the flight-list
|
// a blue-extra-light card attached to the bottom of the flight-list
|
||||||
// frame, with a small `*` sort-note aligned to an inner line holding
|
// frame, with a small `*` sort-note aligned to an inner line holding
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js";
|
||||||
import { useNavigate } from "@modern-js/runtime/router";
|
import { useNavigate } from "@modern-js/runtime/router";
|
||||||
import { useLocale } from "@/i18n/useLocale.js";
|
import { useLocale } from "@/i18n/useLocale.js";
|
||||||
import { useTranslation } from "@/i18n/provider.js";
|
import { useTranslation } from "@/i18n/provider.js";
|
||||||
@@ -334,7 +335,17 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
|
|
||||||
// Data fetching
|
// Data fetching
|
||||||
const searchParams = toSearchParams(params);
|
const searchParams = toSearchParams(params);
|
||||||
const { flights, loading, error, refresh } = useOnlineBoard(searchParams);
|
const { flights, loading, error, refresh, cancel } = useOnlineBoard(searchParams);
|
||||||
|
|
||||||
|
// §4.1.12 — Escape cancels while loader is showing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") cancel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [loading, cancel]);
|
||||||
|
|
||||||
// Live updates via SignalR
|
// Live updates via SignalR
|
||||||
const liveBoardParams = toLiveBoardParams(params);
|
const liveBoardParams = toLiveBoardParams(params);
|
||||||
@@ -401,8 +412,23 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
? buildFlightListJsonLd(displayFlights, searchDescription)
|
? buildFlightListJsonLd(displayFlights, searchDescription)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// §4.1.10.1 — resolve per-status error message key
|
||||||
|
const errorMessageKey = (() => {
|
||||||
|
if (!error) return null;
|
||||||
|
if (error instanceof ApiTimeoutError) return "BOARD.ERROR-TIMEOUT";
|
||||||
|
if (error instanceof ApiHttpError) {
|
||||||
|
return error.status >= 500 ? "BOARD.ERROR-5XX" : "BOARD.ERROR-4XX";
|
||||||
|
}
|
||||||
|
return "BOARD.LOAD-FAILED-MESSAGE";
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="online-board-search" data-testid="online-board-search">
|
<div
|
||||||
|
className="online-board-search"
|
||||||
|
data-testid="online-board-search"
|
||||||
|
// §4.1.10 — flag so CSS can pointer-events:none filter/tabs/breadcrumbs
|
||||||
|
data-searching={loading ? "true" : undefined}
|
||||||
|
>
|
||||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||||
<PageLayout
|
<PageLayout
|
||||||
headerLeft={<PageTabs viewType="onlineboard" />}
|
headerLeft={<PageTabs viewType="onlineboard" />}
|
||||||
@@ -491,7 +517,21 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error state */}
|
{/* §4.1.12 — Cancel search button: visible while loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="online-board-search__loader-bar" data-testid="loader-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="online-board-search__cancel-btn"
|
||||||
|
data-testid="cancel-search-btn"
|
||||||
|
onClick={cancel}
|
||||||
|
>
|
||||||
|
{t("SHARED.SEARCH-CANCEL")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* §4.1.10.1 — Error state with per-status message */}
|
||||||
{error && (
|
{error && (
|
||||||
<section className="frame" data-testid="search-error" role="alert">
|
<section className="frame" data-testid="search-error" role="alert">
|
||||||
<div className="online-board-search__error-card">
|
<div className="online-board-search__error-card">
|
||||||
@@ -499,7 +539,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
{t("BOARD.LOAD-FAILED-TITLE")}
|
{t("BOARD.LOAD-FAILED-TITLE")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="online-board-search__error-message">
|
<p className="online-board-search__error-message">
|
||||||
{t("BOARD.LOAD-FAILED-MESSAGE")}
|
{t(errorMessageKey ?? "BOARD.LOAD-FAILED-MESSAGE")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for useOnlineBoard hook — TZ §4.1.10, §4.1.12
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - AbortController wired: new search aborts the previous in-flight request
|
||||||
|
* - cancel() aborts the controller and resets loading
|
||||||
|
*
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import { useOnlineBoard } from "./useOnlineBoard.js";
|
||||||
|
import * as api from "../api.js";
|
||||||
|
import type { IBoardResponse } from "../types.js";
|
||||||
|
import { ApiClientProvider } from "@/shared/api/provider.js";
|
||||||
|
import type { ApiClient } from "@/shared/api/client.js";
|
||||||
|
|
||||||
|
// Minimal mock API client
|
||||||
|
function makeClient(): ApiClient {
|
||||||
|
return {
|
||||||
|
locale: "ru",
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
} as unknown as ApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWrapper(client: ApiClient) {
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return React.createElement(ApiClientProvider, { client, children });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_RESPONSE: IBoardResponse = {
|
||||||
|
data: { routes: [], partners: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useOnlineBoard — §4.1.12 cancellation", () => {
|
||||||
|
const baseParams = {
|
||||||
|
dateFrom: "2026-01-15T00:00:00",
|
||||||
|
dateTo: "2026-01-16T00:00:00",
|
||||||
|
departure: "SVO",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes AbortSignal to searchFlights", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const spy = vi.spyOn(api, "searchFlights").mockResolvedValue(EMPTY_RESPONSE);
|
||||||
|
|
||||||
|
renderHook(() => useOnlineBoard(baseParams), {
|
||||||
|
wrapper: makeWrapper(client),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the async effect
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
|
const [, , signal] = spy.mock.calls[0] as [unknown, unknown, AbortSignal | undefined];
|
||||||
|
expect(signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel() aborts the request and sets loading to false", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
// Never resolve — simulates a slow in-flight request
|
||||||
|
vi.spyOn(api, "searchFlights").mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnlineBoard(baseParams), {
|
||||||
|
wrapper: makeWrapper(client),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be loading initially
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("new param change aborts the previous in-flight request (§4.1.12)", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const capturedSignals: AbortSignal[] = [];
|
||||||
|
|
||||||
|
vi.spyOn(api, "searchFlights").mockImplementation((_c, _p, signal) => {
|
||||||
|
if (signal) capturedSignals.push(signal);
|
||||||
|
return new Promise(() => {}); // Never resolves
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
(params) => useOnlineBoard(params),
|
||||||
|
{
|
||||||
|
initialProps: baseParams,
|
||||||
|
wrapper: makeWrapper(client),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for first effect
|
||||||
|
await act(async () => { await Promise.resolve(); });
|
||||||
|
expect(capturedSignals).toHaveLength(1);
|
||||||
|
|
||||||
|
// Change params — triggers new effect which aborts the old controller
|
||||||
|
rerender({ ...baseParams, departure: "LED" });
|
||||||
|
await act(async () => { await Promise.resolve(); });
|
||||||
|
|
||||||
|
// First signal should now be aborted
|
||||||
|
expect(capturedSignals[0]?.aborted).toBe(true);
|
||||||
|
// Second request was started with a fresh (non-aborted) controller
|
||||||
|
expect(capturedSignals).toHaveLength(2);
|
||||||
|
expect(capturedSignals[1]?.aborted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* React hook for the online board search pages.
|
* React hook for the online board search pages.
|
||||||
*
|
*
|
||||||
* Calls `searchFlights` on param change, manages loading/error/data state.
|
* Calls `searchFlights` on param change, manages loading/error/data state.
|
||||||
|
* Supports AbortController-based cancellation (§4.1.12).
|
||||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
@@ -19,11 +20,13 @@ export interface UseOnlineBoardResult {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: ApiError | null;
|
error: ApiError | null;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
|
/** Cancel the in-flight request (§4.1.12). Resets loading to false. */
|
||||||
|
cancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for online board search pages. Fetches flights based on search params
|
* Hook for online board search pages. Fetches flights based on search params
|
||||||
* and provides refresh capability.
|
* and provides refresh + cancel capability.
|
||||||
*/
|
*/
|
||||||
export function useOnlineBoard(
|
export function useOnlineBoard(
|
||||||
params: SearchFlightsParams,
|
params: SearchFlightsParams,
|
||||||
@@ -38,10 +41,21 @@ export function useOnlineBoard(
|
|||||||
const paramsRef = useRef(params);
|
const paramsRef = useRef(params);
|
||||||
paramsRef.current = params;
|
paramsRef.current = params;
|
||||||
|
|
||||||
|
// AbortController for the current in-flight request (§4.1.12)
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Callers pass empty strings when the search context isn't resolved
|
// Callers pass empty strings when the search context isn't resolved
|
||||||
// yet (e.g. details page with no `?request=...`). Skip the fetch
|
// yet (e.g. details page with no `?request=...`). Skip the fetch
|
||||||
@@ -54,27 +68,34 @@ export function useOnlineBoard(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
// Abort any previous in-flight request (§4.1.12 — new search aborts in-flight)
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
searchFlights(client, paramsRef.current)
|
searchFlights(client, paramsRef.current, controller.signal)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!cancelled) {
|
if (!controller.signal.aborted) {
|
||||||
setFlights(response.data?.routes ?? []);
|
setFlights(response.data?.routes ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
|
// Ignore aborted requests — the user cancelled intentionally
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
console.error("[useOnlineBoard] API error:", err);
|
console.error("[useOnlineBoard] API error:", err);
|
||||||
if (!cancelled) {
|
setError(err as ApiError);
|
||||||
setError(err as ApiError);
|
setLoading(false);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
controller.abort();
|
||||||
|
abortRef.current = null;
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
@@ -88,5 +109,5 @@ export function useOnlineBoard(
|
|||||||
refreshKey,
|
refreshKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { flights, loading, error, refresh };
|
return { flights, loading, error, refresh, cancel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
export async function searchSchedule(
|
export async function searchSchedule(
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
params: IScheduleSearchRequest,
|
params: IScheduleSearchRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<IScheduleResponse> {
|
): Promise<IScheduleResponse> {
|
||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
departure: params.departure,
|
departure: params.departure,
|
||||||
@@ -47,6 +48,7 @@ export async function searchSchedule(
|
|||||||
return client.get<IScheduleResponse>(
|
return client.get<IScheduleResponse>(
|
||||||
`flights/1/${client.locale}/schedule`,
|
`flights/1/${client.locale}/schedule`,
|
||||||
query,
|
query,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* search is route-only.
|
* search is route-only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type FC, useState, useCallback, useRef, useEffect, type FormEvent } from "react";
|
import { type FC, useState, useCallback, useRef, useEffect, useMemo, type FormEvent } from "react";
|
||||||
import { useNavigate } from "@modern-js/runtime/router";
|
import { useNavigate } from "@modern-js/runtime/router";
|
||||||
import { Calendar } from "primereact/calendar";
|
import { Calendar } from "primereact/calendar";
|
||||||
import { Slider, type SliderChangeEvent } from "primereact/slider";
|
import { Slider, type SliderChangeEvent } from "primereact/slider";
|
||||||
@@ -136,6 +136,20 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
const scheduleMinDate = useRef(getScheduleMinDate()).current;
|
const scheduleMinDate = useRef(getScheduleMinDate()).current;
|
||||||
const scheduleMaxDate = useRef(getScheduleMaxDate()).current;
|
const scheduleMaxDate = useRef(getScheduleMaxDate()).current;
|
||||||
|
|
||||||
|
// §4.1.11 — submit button locked for 30 seconds after each search.
|
||||||
|
// The 30-second constant is intentionally hardcoded (not configurable).
|
||||||
|
const [submitLockedUntil, setSubmitLockedUntil] = useState(0);
|
||||||
|
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (submitLockedUntil === 0 || nowTs >= submitLockedUntil) return;
|
||||||
|
const id = setTimeout(() => setNowTs(Date.now()), 1000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [submitLockedUntil, nowTs]);
|
||||||
|
const isSubmitLocked = useMemo(
|
||||||
|
() => submitLockedUntil > 0 && nowTs < submitLockedUntil,
|
||||||
|
[submitLockedUntil, nowTs],
|
||||||
|
);
|
||||||
|
|
||||||
// Swap the Calendar input's displayed text to "Текущая неделя" per
|
// Swap the Calendar input's displayed text to "Текущая неделя" per
|
||||||
// TZ §4.1.9 Table 14 when the selected range equals Mon-Sun of the
|
// TZ §4.1.9 Table 14 when the selected range equals Mon-Sun of the
|
||||||
// current week. Uses inputRef + useEffect to override PrimeReact's
|
// current week. Uses inputRef + useEffect to override PrimeReact's
|
||||||
@@ -159,6 +173,7 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent<HTMLFormElement>) => {
|
(e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isSubmitLocked) return;
|
||||||
const dep = departure.trim().toUpperCase();
|
const dep = departure.trim().toUpperCase();
|
||||||
const arr = arrival.trim().toUpperCase();
|
const arr = arrival.trim().toUpperCase();
|
||||||
if (!dep || !arr) return;
|
if (!dep || !arr) return;
|
||||||
@@ -258,6 +273,9 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
params = { type: "route", outbound };
|
params = { type: "route", outbound };
|
||||||
}
|
}
|
||||||
const url = buildScheduleUrl(params);
|
const url = buildScheduleUrl(params);
|
||||||
|
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
|
||||||
|
setSubmitLockedUntil(Date.now() + 30_000);
|
||||||
|
setNowTs(Date.now());
|
||||||
void navigate(`/${locale}/${url}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -271,6 +289,7 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
returnTimeRange,
|
returnTimeRange,
|
||||||
navigate,
|
navigate,
|
||||||
locale,
|
locale,
|
||||||
|
isSubmitLocked,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -531,6 +550,8 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="search-button"
|
className="search-button"
|
||||||
data-testid="search-submit"
|
data-testid="search-submit"
|
||||||
|
disabled={isSubmitLocked}
|
||||||
|
aria-disabled={isSubmitLocked}
|
||||||
>
|
>
|
||||||
<span>{t("SHARED.SCHEDULES_VIEW")}</span>
|
<span>{t("SHARED.SCHEDULES_VIEW")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,6 +16,40 @@
|
|||||||
color: colors.$red;
|
color: colors.$red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §4.1.12 — loader bar with cancel button
|
||||||
|
&__loader-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: vars.$space-m vars.$space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cancel-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: vars.$space-s2 vars.$space-xl;
|
||||||
|
background-color: colors.$blue-light;
|
||||||
|
color: colors.$white;
|
||||||
|
border: none;
|
||||||
|
border-radius: vars.$border-radius;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: fonts.$font-size-m;
|
||||||
|
font-weight: fonts.$font-medium;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: colors.$blue-light--hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.1.11 — block filter/tabs/breadcrumbs while loading
|
||||||
|
&[data-searching="true"] {
|
||||||
|
.page-layout__left,
|
||||||
|
.page-layout__sticky,
|
||||||
|
.page-layout__breadcrumbs {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__outbound,
|
&__outbound,
|
||||||
&__inbound {
|
&__inbound {
|
||||||
padding: vars.$space-xl 0;
|
padding: vars.$space-xl 0;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js";
|
||||||
import { useNavigate } from "@modern-js/runtime/router";
|
import { useNavigate } from "@modern-js/runtime/router";
|
||||||
import { useLocale } from "@/i18n/useLocale.js";
|
import { useLocale } from "@/i18n/useLocale.js";
|
||||||
import { useTranslation } from "@/i18n/provider.js";
|
import { useTranslation } from "@/i18n/provider.js";
|
||||||
@@ -263,7 +264,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
|||||||
|
|
||||||
// Fetch outbound flights
|
// Fetch outbound flights
|
||||||
const outboundRequest = toSearchRequest(outbound);
|
const outboundRequest = toSearchRequest(outbound);
|
||||||
const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh } =
|
const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh, cancel: cancelOutbound } =
|
||||||
useScheduleSearch(outboundRequest);
|
useScheduleSearch(outboundRequest);
|
||||||
|
|
||||||
// Fetch inbound flights (if round-trip)
|
// Fetch inbound flights (if round-trip)
|
||||||
@@ -271,9 +272,36 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
|||||||
const {
|
const {
|
||||||
flights: inboundFlights,
|
flights: inboundFlights,
|
||||||
loading: inboundLoading,
|
loading: inboundLoading,
|
||||||
|
cancel: cancelInbound,
|
||||||
} = useScheduleSearch(inboundRequest);
|
} = useScheduleSearch(inboundRequest);
|
||||||
|
|
||||||
const _loading = outboundLoading || (inbound ? inboundLoading : false);
|
const isLoading = outboundLoading || (inbound ? inboundLoading : false);
|
||||||
|
|
||||||
|
// §4.1.12 — cancel both directions at once
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
cancelOutbound();
|
||||||
|
cancelInbound();
|
||||||
|
}, [cancelOutbound, cancelInbound]);
|
||||||
|
|
||||||
|
// §4.1.12 — Escape cancels while loader is showing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") cancel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isLoading, cancel]);
|
||||||
|
|
||||||
|
// §4.1.11.1 — per-status error message key
|
||||||
|
const errorMessageKey = (() => {
|
||||||
|
if (!outboundError) return null;
|
||||||
|
if (outboundError instanceof ApiTimeoutError) return "BOARD.ERROR-TIMEOUT";
|
||||||
|
if (outboundError instanceof ApiHttpError) {
|
||||||
|
return outboundError.status >= 500 ? "BOARD.ERROR-5XX" : "BOARD.ERROR-4XX";
|
||||||
|
}
|
||||||
|
return "BOARD.LOAD-FAILED";
|
||||||
|
})();
|
||||||
|
|
||||||
/** Navigate to a different week (Monday → Sunday range). Updates the
|
/** Navigate to a different week (Monday → Sunday range). Updates the
|
||||||
* active direction's params and preserves the other direction. */
|
* active direction's params and preserves the other direction. */
|
||||||
@@ -340,7 +368,12 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="schedule-search" data-testid="schedule-search">
|
<div
|
||||||
|
className="schedule-search"
|
||||||
|
data-testid="schedule-search"
|
||||||
|
// §4.1.11 — block filter/tabs/breadcrumbs while searching
|
||||||
|
data-searching={isLoading ? "true" : undefined}
|
||||||
|
>
|
||||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||||
<PageLayout
|
<PageLayout
|
||||||
headerLeft={<PageTabs viewType="schedule" />}
|
headerLeft={<PageTabs viewType="schedule" />}
|
||||||
@@ -435,10 +468,25 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* §4.1.12 — cancel button visible while loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="schedule-search__loader-bar" data-testid="loader-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-search__cancel-btn"
|
||||||
|
data-testid="cancel-search-btn"
|
||||||
|
onClick={cancel}
|
||||||
|
>
|
||||||
|
{t("SHARED.SEARCH-CANCEL")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* §4.1.11.1 — per-status error message */}
|
||||||
{outboundError && (
|
{outboundError && (
|
||||||
<section className="frame" data-testid="search-error">
|
<section className="frame" data-testid="search-error" role="alert">
|
||||||
<div className="schedule-search__error">
|
<div className="schedule-search__error">
|
||||||
<p>{t("BOARD.LOAD-FAILED")}</p>
|
<p>{t(errorMessageKey ?? "BOARD.LOAD-FAILED")}</p>
|
||||||
<button type="button" onClick={refresh}>{t("SHARED.RETRY")}</button>
|
<button type="button" onClick={refresh}>{t("SHARED.RETRY")}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* React hook for schedule search pages.
|
* React hook for schedule search pages.
|
||||||
*
|
*
|
||||||
* Calls `searchSchedule` on param change, manages loading/error/data state.
|
* Calls `searchSchedule` on param change, manages loading/error/data state.
|
||||||
|
* Supports AbortController-based cancellation (§4.1.12).
|
||||||
* No SignalR -- schedule data is static.
|
* No SignalR -- schedule data is static.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
@@ -18,11 +19,13 @@ export interface UseScheduleSearchResult {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: ApiError | null;
|
error: ApiError | null;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
|
/** Cancel the in-flight request (§4.1.12). Resets loading to false. */
|
||||||
|
cancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for schedule search pages. Fetches flights based on search params
|
* Hook for schedule search pages. Fetches flights based on search params
|
||||||
* and provides refresh capability.
|
* and provides refresh + cancel capability.
|
||||||
*/
|
*/
|
||||||
export function useScheduleSearch(
|
export function useScheduleSearch(
|
||||||
params: IScheduleSearchRequest,
|
params: IScheduleSearchRequest,
|
||||||
@@ -36,31 +39,49 @@ export function useScheduleSearch(
|
|||||||
const paramsRef = useRef(params);
|
const paramsRef = useRef(params);
|
||||||
paramsRef.current = params;
|
paramsRef.current = params;
|
||||||
|
|
||||||
|
// AbortController for the current in-flight request (§4.1.12)
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
// Abort any previous in-flight request (§4.1.12 — new search aborts in-flight)
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
searchSchedule(client, paramsRef.current)
|
searchSchedule(client, paramsRef.current, controller.signal)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!cancelled) {
|
if (!controller.signal.aborted) {
|
||||||
setFlights(response);
|
setFlights(response);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: ApiError) => {
|
.catch((err: ApiError) => {
|
||||||
if (!cancelled) {
|
// Ignore aborted requests — the user cancelled intentionally
|
||||||
setError(err);
|
if (controller.signal.aborted) return;
|
||||||
setLoading(false);
|
setError(err);
|
||||||
}
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
controller.abort();
|
||||||
|
abortRef.current = null;
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
@@ -74,5 +95,5 @@ export function useScheduleSearch(
|
|||||||
refreshKey,
|
refreshKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { flights, loading, error, refresh };
|
return { flights, loading, error, refresh, cancel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
"LOAD-FAILED-TITLE": "Failed to load data",
|
"LOAD-FAILED-TITLE": "Failed to load data",
|
||||||
"LOAD-FAILED-MESSAGE": "API server is unavailable. Check your connection and try again.",
|
"LOAD-FAILED-MESSAGE": "API server is unavailable. Check your connection and try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BREADCRUMBS": {
|
"BREADCRUMBS": {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
"LOAD-FAILED": "Не удалось загрузить данные. Попробуйте снова.",
|
"LOAD-FAILED": "Не удалось загрузить данные. Попробуйте снова.",
|
||||||
"LOAD-FAILED-TITLE": "Не удалось загрузить данные",
|
"LOAD-FAILED-TITLE": "Не удалось загрузить данные",
|
||||||
"LOAD-FAILED-MESSAGE": "API сервер недоступен. Проверьте подключение и попробуйте снова.",
|
"LOAD-FAILED-MESSAGE": "API сервер недоступен. Проверьте подключение и попробуйте снова.",
|
||||||
|
"ERROR-TIMEOUT": "Время ожидания ответа от сервера истекло. Попробуйте снова.",
|
||||||
|
"ERROR-4XX": "Неверные параметры поиска. Проверьте введённые данные и попробуйте снова.",
|
||||||
|
"ERROR-5XX": "Сервер временно недоступен. Пожалуйста, повторите попытку позже.",
|
||||||
"OPERATED-BY": "Выполняет рейс"
|
"OPERATED-BY": "Выполняет рейс"
|
||||||
},
|
},
|
||||||
"BREADCRUMBS": {
|
"BREADCRUMBS": {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
"ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.",
|
||||||
"FLIGHT-NOT-FOUND": "Flight not found.",
|
"FLIGHT-NOT-FOUND": "Flight not found.",
|
||||||
"LOAD-FAILED": "Failed to load data. Please try again.",
|
"LOAD-FAILED": "Failed to load data. Please try again.",
|
||||||
|
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
|
||||||
|
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
|
||||||
|
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
|
||||||
"OPERATED-BY": "Operated by"
|
"OPERATED-BY": "Operated by"
|
||||||
},
|
},
|
||||||
"BOARDING-STATUSES": {
|
"BOARDING-STATUSES": {
|
||||||
|
|||||||
@@ -58,18 +58,19 @@ export class ApiClient {
|
|||||||
async get<T>(
|
async get<T>(
|
||||||
path: string,
|
path: string,
|
||||||
query?: Record<string, string | number | boolean>,
|
query?: Record<string, string | number | boolean>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = this.buildUrl(path, query);
|
const url = this.buildUrl(path, query);
|
||||||
return this.executeWithRetry<T>(url, { method: "GET" });
|
return this.executeWithRetry<T>(url, { method: "GET" }, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async post<T>(path: string, body: unknown): Promise<T> {
|
async post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||||
const url = this.buildUrl(path);
|
const url = this.buildUrl(path);
|
||||||
return this.executeWithRetry<T>(url, {
|
return this.executeWithRetry<T>(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildUrl(
|
private buildUrl(
|
||||||
@@ -89,6 +90,7 @@ export class ApiClient {
|
|||||||
private async executeWithRetry<T>(
|
private async executeWithRetry<T>(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
|
externalSignal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = new Headers(init.headers ?? {});
|
const headers = new Headers(init.headers ?? {});
|
||||||
headers.set("Accept-Language", this.locale);
|
headers.set("Accept-Language", this.locale);
|
||||||
@@ -106,11 +108,16 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abort immediately if the external signal fired before we started.
|
||||||
|
if (externalSignal?.aborted) {
|
||||||
|
throw new ApiNetworkError(new Error("Aborted"));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
});
|
}, externalSignal);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
@@ -150,6 +157,11 @@ export class ApiClient {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
// External cancellation — don't retry, re-throw as-is so the
|
||||||
|
// caller can distinguish "user cancelled" from "network error".
|
||||||
|
if (err.name === "AbortError" || (externalSignal?.aborted)) {
|
||||||
|
throw new ApiNetworkError(err);
|
||||||
|
}
|
||||||
if (attempt < this.maxRetries) {
|
if (attempt < this.maxRetries) {
|
||||||
lastError = new ApiNetworkError(err);
|
lastError = new ApiNetworkError(err);
|
||||||
continue;
|
continue;
|
||||||
@@ -166,10 +178,19 @@ export class ApiClient {
|
|||||||
private async fetchWithTimeout(
|
private async fetchWithTimeout(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
|
externalSignal?: AbortSignal,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||||
|
|
||||||
|
// Forward external cancellation (e.g. user clicks "Отменить поиск")
|
||||||
|
// to the internal controller so the fetch is actually aborted.
|
||||||
|
let externalListener: (() => void) | undefined;
|
||||||
|
if (externalSignal) {
|
||||||
|
externalListener = () => controller.abort();
|
||||||
|
externalSignal.addEventListener("abort", externalListener);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.fetchFn(url, {
|
return await this.fetchFn(url, {
|
||||||
...init,
|
...init,
|
||||||
@@ -177,11 +198,18 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
// Distinguish user cancellation from timeout.
|
||||||
|
if (externalSignal?.aborted) {
|
||||||
|
throw err; // Let the caller handle it as a cancellation.
|
||||||
|
}
|
||||||
throw new ApiTimeoutError(this.timeoutMs);
|
throw new ApiTimeoutError(this.timeoutMs);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
if (externalSignal && externalListener) {
|
||||||
|
externalSignal.removeEventListener("abort", externalListener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user