Allow changed schedule searches after submit
This commit is contained in:
@@ -90,7 +90,10 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
|
|||||||
CityAutocomplete: (props: Record<string, unknown>) => (
|
CityAutocomplete: (props: Record<string, unknown>) => (
|
||||||
<input
|
<input
|
||||||
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-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 }) => (
|
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();
|
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.
|
// §4.1.11 — submit button locked for 30 seconds after each search.
|
||||||
// The 30-second constant is intentionally hardcoded (not configurable).
|
// 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());
|
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (submitLockedUntil === 0 || nowTs >= submitLockedUntil) return;
|
if (!submitLock || nowTs >= submitLock.until) return;
|
||||||
const id = setTimeout(() => setNowTs(Date.now()), 1000);
|
const id = setTimeout(() => setNowTs(Date.now()), 1000);
|
||||||
return () => clearTimeout(id);
|
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(
|
const isSubmitLocked = useMemo(
|
||||||
() => submitLockedUntil > 0 && nowTs < submitLockedUntil,
|
() => Boolean(submitLock && nowTs < submitLock.until && submitLock.key === formSearchKey),
|
||||||
[submitLockedUntil, nowTs],
|
[submitLock, nowTs, formSearchKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TZ §4.1.9.4 Table 16: when the outbound range moves forward such
|
// 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,
|
searchExecuted: true,
|
||||||
});
|
});
|
||||||
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
|
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
|
||||||
setSubmitLockedUntil(Date.now() + 30_000);
|
const submittedAt = Date.now();
|
||||||
setNowTs(Date.now());
|
setSubmitLock({ until: submittedAt + 30_000, key: formSearchKey });
|
||||||
|
setNowTs(submittedAt);
|
||||||
void navigate(`/${locale}/${url}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -506,6 +534,7 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
navigate,
|
navigate,
|
||||||
locale,
|
locale,
|
||||||
isSubmitLocked,
|
isSubmitLocked,
|
||||||
|
formSearchKey,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -156,36 +156,38 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
|||||||
|
|
||||||
const add = useCallback(
|
const add = useCallback(
|
||||||
(item: SearchHistoryItem) => {
|
(item: SearchHistoryItem) => {
|
||||||
setItems((prev) => {
|
const prev =
|
||||||
// Deduplicate by URL — don't add if the most recent entry matches
|
(sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||||
if (prev[0]?.url === item.url) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any existing entry with the same URL, then prepend.
|
// Deduplicate by URL — don't add if the most recent entry matches.
|
||||||
const filtered = prev.filter((existing) => existing.url !== item.url);
|
if (prev[0]?.url === item.url) {
|
||||||
const combined = [item, ...filtered];
|
setItems(prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cap per section — the board/schedule/flight-number buckets each
|
// Remove any existing entry with the same URL, then prepend.
|
||||||
// hold up to MAX_PER_SECTION (8) entries independently. This keeps
|
const filtered = prev.filter((existing) => existing.url !== item.url);
|
||||||
// the resulting list ordered by recency across sections but
|
const combined = [item, ...filtered];
|
||||||
// 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);
|
// Cap per section — the board/schedule/flight-number buckets each
|
||||||
if (typeof window !== "undefined") {
|
// hold up to MAX_PER_SECTION (8) entries independently. This keeps
|
||||||
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
// the resulting list ordered by recency across sections but
|
||||||
}
|
// prevents a burst of board searches from evicting schedule
|
||||||
return next;
|
// 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],
|
[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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user