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>) => (
|
||||
<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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -156,10 +156,13 @@ 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
|
||||
const prev =
|
||||
(sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||
|
||||
// Deduplicate by URL — don't add if the most recent entry matches.
|
||||
if (prev[0]?.url === item.url) {
|
||||
return prev;
|
||||
setItems(prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing entry with the same URL, then prepend.
|
||||
@@ -181,11 +184,10 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
||||
}
|
||||
|
||||
sessionStore.set(key, next, searchHistorySchema);
|
||||
setItems(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[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