Allow changed schedule searches after submit

This commit is contained in:
2026-05-14 22:36:07 +03:00
parent 17476e4a89
commit 43ef9bb710
4 changed files with 142 additions and 35 deletions
@@ -90,7 +90,10 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
CityAutocomplete: (props: Record<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
value={(props["value"] as string) ?? ""}
onChange={(e) => {
(props["onChange"] as (value: string) => void)(e.currentTarget.value);
}}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
@@ -468,3 +471,40 @@ describe("ScheduleFilter validation per TZ §4.1.9.4", () => {
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
});
});
describe("ScheduleFilter repeated-submit lock", () => {
beforeEach(() => {
vi.clearAllMocks();
scheduleCalendarMock.days = [];
scheduleCalendarMock.loaded = false;
scheduleCalendarMock.params = [];
_sliderOnChange = null;
_sliderOnChanges.length = 0;
});
it("keeps identical resubmits locked but allows an immediate changed search", () => {
render(
<ScheduleFilter
initialDeparture="SVO"
initialArrival="LED"
initialDateFrom="20260601"
initialDateTo="20260607"
/>,
);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByTestId("schedule-arrival-input"), {
target: { value: "KUF" },
});
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(2);
expect(mockNavigate).toHaveBeenLastCalledWith(
"/ru-ru/schedule/route/SVO-KUF-20260601-20260607",
);
});
});
@@ -253,16 +253,43 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
// §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 [submitLock, setSubmitLock] = useState<{ until: number; key: string } | null>(null);
const [nowTs, setNowTs] = useState(() => Date.now());
useEffect(() => {
if (submitLockedUntil === 0 || nowTs >= submitLockedUntil) return;
if (!submitLock || nowTs >= submitLock.until) return;
const id = setTimeout(() => setNowTs(Date.now()), 1000);
return () => clearTimeout(id);
}, [submitLockedUntil, nowTs]);
}, [submitLock, nowTs]);
const formSearchKey = useMemo(
() =>
JSON.stringify({
departure: departure.trim().toUpperCase(),
arrival: arrival.trim().toUpperCase(),
dateFrom: dateRange[0] ? dateToYyyymmdd(dateRange[0]) : "",
dateTo: dateRange[1] ? dateToYyyymmdd(dateRange[1]) : "",
timeFrom: timeRange[0],
timeTo: timeRange[1],
directOnly,
returnFlights,
returnDateFrom: returnDateRange[0] ? dateToYyyymmdd(returnDateRange[0]) : "",
returnDateTo: returnDateRange[1] ? dateToYyyymmdd(returnDateRange[1]) : "",
returnTimeFrom: returnTimeRange[0],
returnTimeTo: returnTimeRange[1],
}),
[
departure,
arrival,
dateRange,
timeRange,
directOnly,
returnFlights,
returnDateRange,
returnTimeRange,
],
);
const isSubmitLocked = useMemo(
() => submitLockedUntil > 0 && nowTs < submitLockedUntil,
[submitLockedUntil, nowTs],
() => Boolean(submitLock && nowTs < submitLock.until && submitLock.key === formSearchKey),
[submitLock, nowTs, formSearchKey],
);
// TZ §4.1.9.4 Table 16: when the outbound range moves forward such
@@ -490,8 +517,9 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
searchExecuted: true,
});
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
setSubmitLockedUntil(Date.now() + 30_000);
setNowTs(Date.now());
const submittedAt = Date.now();
setSubmitLock({ until: submittedAt + 30_000, key: formSearchKey });
setNowTs(submittedAt);
void navigate(`/${locale}/${url}`);
},
[
@@ -506,6 +534,7 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
navigate,
locale,
isSubmitLocked,
formSearchKey,
],
);
+29 -27
View File
@@ -156,36 +156,38 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
const add = useCallback(
(item: SearchHistoryItem) => {
setItems((prev) => {
// Deduplicate by URL — don't add if the most recent entry matches
if (prev[0]?.url === item.url) {
return prev;
}
const prev =
(sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
// Remove any existing entry with the same URL, then prepend.
const filtered = prev.filter((existing) => existing.url !== item.url);
const combined = [item, ...filtered];
// Deduplicate by URL — don't add if the most recent entry matches.
if (prev[0]?.url === item.url) {
setItems(prev);
return;
}
// Cap per section — the board/schedule/flight-number buckets each
// hold up to MAX_PER_SECTION (8) entries independently. This keeps
// the resulting list ordered by recency across sections but
// prevents a burst of board searches from evicting schedule
// history (and vice versa).
const perSectionCount = { board: 0, schedule: 0, "flight-number": 0 };
const next: SearchHistoryItem[] = [];
for (const entry of combined) {
const bucket = sectionFor(entry.type);
if (perSectionCount[bucket] >= MAX_PER_SECTION) continue;
perSectionCount[bucket]++;
next.push(entry);
}
// Remove any existing entry with the same URL, then prepend.
const filtered = prev.filter((existing) => existing.url !== item.url);
const combined = [item, ...filtered];
sessionStore.set(key, next, searchHistorySchema);
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
}
return next;
});
// Cap per section — the board/schedule/flight-number buckets each
// hold up to MAX_PER_SECTION (8) entries independently. This keeps
// the resulting list ordered by recency across sections but
// prevents a burst of board searches from evicting schedule
// history (and vice versa).
const perSectionCount = { board: 0, schedule: 0, "flight-number": 0 };
const next: SearchHistoryItem[] = [];
for (const entry of combined) {
const bucket = sectionFor(entry.type);
if (perSectionCount[bucket] >= MAX_PER_SECTION) continue;
perSectionCount[bucket]++;
next.push(entry);
}
sessionStore.set(key, next, searchHistorySchema);
setItems(next);
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
}
},
[key],
);
@@ -0,0 +1,36 @@
import { test, expect } from "./fixtures/console-gate";
test.describe("Schedule results filter", () => {
test("changed route criteria can be submitted immediately after a previous search", async ({
page,
consoleMessages,
}) => {
await page.goto("/ru-ru/schedule/route/VVO-MJZ-20260518-20260524");
await expect(page.locator("h1")).toContainText(/Владивосток.*Мирный|VVO.*MJZ/, {
timeout: 30000,
});
await expect(page.getByTestId("loader-bar")).toBeHidden({ timeout: 30000 });
const submit = page.getByTestId("search-submit");
await expect(submit).toBeEnabled();
await submit.click();
await expect(submit).toBeDisabled();
await page.getByTestId("swap-cities-button").click();
await expect(submit).toBeEnabled();
const scheduleResponse = page.waitForResponse(
(response) =>
response.url().includes("/api/flights/1/ru/schedule") &&
response.url().includes("departure=MJZ") &&
response.url().includes("arrival=VVO"),
);
await submit.click();
await expect(page).toHaveURL(/\/ru-ru\/schedule\/route\/MJZ-VVO-20260518-20260524/);
expect((await scheduleResponse).status()).toBe(200);
expect(consoleMessages).toEqual([]);
});
});