Align schedule filter submit parity
This commit is contained in:
@@ -470,9 +470,28 @@ describe("ScheduleFilter – validation per TZ §4.1.9.4", () => {
|
|||||||
expect(screen.queryByTestId("schedule-date-clear")).toBeNull();
|
expect(screen.queryByTestId("schedule-date-clear")).toBeNull();
|
||||||
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
|
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears stale return dates when return flights toggle changes", () => {
|
||||||
|
render(
|
||||||
|
<ScheduleFilter
|
||||||
|
initialDeparture="SVO"
|
||||||
|
initialArrival="LED"
|
||||||
|
initialDateFrom="20260601"
|
||||||
|
initialDateTo="20260607"
|
||||||
|
initialReturnFlights={true}
|
||||||
|
initialReturnDateFrom="20260608"
|
||||||
|
initialReturnDateTo="20260614"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("schedule-return-date-clear")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByTestId("schedule-return-flights").querySelector("input")!);
|
||||||
|
fireEvent.click(screen.getByTestId("schedule-return-flights").querySelector("input")!);
|
||||||
|
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ScheduleFilter – repeated-submit lock", () => {
|
describe("ScheduleFilter – submit parity", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
scheduleCalendarMock.days = [];
|
scheduleCalendarMock.days = [];
|
||||||
@@ -482,7 +501,7 @@ describe("ScheduleFilter – repeated-submit lock", () => {
|
|||||||
_sliderOnChanges.length = 0;
|
_sliderOnChanges.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps identical resubmits locked but allows an immediate changed search", () => {
|
it("allows immediate repeated submits like Angular", () => {
|
||||||
render(
|
render(
|
||||||
<ScheduleFilter
|
<ScheduleFilter
|
||||||
initialDeparture="SVO"
|
initialDeparture="SVO"
|
||||||
@@ -494,7 +513,10 @@ describe("ScheduleFilter – repeated-submit lock", () => {
|
|||||||
|
|
||||||
fireEvent.submit(screen.getByTestId("search-form"));
|
fireEvent.submit(screen.getByTestId("search-form"));
|
||||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||||
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(true);
|
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId("search-form"));
|
||||||
|
expect(mockNavigate).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId("schedule-arrival-input"), {
|
fireEvent.change(screen.getByTestId("schedule-arrival-input"), {
|
||||||
target: { value: "KUF" },
|
target: { value: "KUF" },
|
||||||
@@ -502,7 +524,7 @@ describe("ScheduleFilter – repeated-submit lock", () => {
|
|||||||
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
|
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
fireEvent.submit(screen.getByTestId("search-form"));
|
fireEvent.submit(screen.getByTestId("search-form"));
|
||||||
expect(mockNavigate).toHaveBeenCalledTimes(2);
|
expect(mockNavigate).toHaveBeenCalledTimes(3);
|
||||||
expect(mockNavigate).toHaveBeenLastCalledWith(
|
expect(mockNavigate).toHaveBeenLastCalledWith(
|
||||||
"/ru-ru/schedule/route/SVO-KUF-20260601-20260607",
|
"/ru-ru/schedule/route/SVO-KUF-20260601-20260607",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -251,47 +251,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
||||||
);
|
);
|
||||||
|
|
||||||
// §4.1.11 — submit button locked for 30 seconds after each search.
|
|
||||||
// The 30-second constant is intentionally hardcoded (not configurable).
|
|
||||||
const [submitLock, setSubmitLock] = useState<{ until: number; key: string } | null>(null);
|
|
||||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
|
||||||
useEffect(() => {
|
|
||||||
if (!submitLock || nowTs >= submitLock.until) return;
|
|
||||||
const id = setTimeout(() => setNowTs(Date.now()), 1000);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [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(
|
|
||||||
() => 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
|
// TZ §4.1.9.4 Table 16: when the outbound range moves forward such
|
||||||
// that the already-chosen return starts before the new outbound
|
// that the already-chosen return starts before the new outbound
|
||||||
// dateTo, blank the return picker and any coupled error so the user
|
// dateTo, blank the return picker and any coupled error so the user
|
||||||
@@ -393,7 +352,6 @@ 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;
|
||||||
@@ -516,10 +474,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
: {}),
|
: {}),
|
||||||
searchExecuted: true,
|
searchExecuted: true,
|
||||||
});
|
});
|
||||||
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
|
|
||||||
const submittedAt = Date.now();
|
|
||||||
setSubmitLock({ until: submittedAt + 30_000, key: formSearchKey });
|
|
||||||
setNowTs(submittedAt);
|
|
||||||
void navigate(`/${locale}/${url}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -533,8 +487,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
returnTimeRange,
|
returnTimeRange,
|
||||||
navigate,
|
navigate,
|
||||||
locale,
|
locale,
|
||||||
isSubmitLocked,
|
|
||||||
formSearchKey,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -691,7 +643,11 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={returnFlights}
|
checked={returnFlights}
|
||||||
onChange={(e) => setReturnFlights(e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setReturnFlights(e.target.checked);
|
||||||
|
setReturnDateRange([null, null]);
|
||||||
|
setReturnBeforeOutboundError(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{t("SHARED.RETURN_FLIGHT_VIEW")}</span>
|
<span>{t("SHARED.RETURN_FLIGHT_VIEW")}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -794,8 +750,6 @@ 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>
|
||||||
|
|||||||
@@ -40,11 +40,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// §4.1.11 — block filter/tabs/breadcrumbs while loading
|
// Angular parity: loading blocks only the sticky results controls.
|
||||||
|
// The sidebar filter stays interactive so users can correct criteria
|
||||||
|
// while the previous request is still in-flight.
|
||||||
&[data-searching="true"] {
|
&[data-searching="true"] {
|
||||||
.page-layout__left,
|
.page-layout__sticky {
|
||||||
.page-layout__sticky,
|
|
||||||
.page-layout__breadcrumbs {
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test.describe("Schedule results filter", () => {
|
|||||||
const submit = page.getByTestId("search-submit");
|
const submit = page.getByTestId("search-submit");
|
||||||
await expect(submit).toBeEnabled();
|
await expect(submit).toBeEnabled();
|
||||||
await submit.click();
|
await submit.click();
|
||||||
await expect(submit).toBeDisabled();
|
await expect(submit).toBeEnabled();
|
||||||
|
|
||||||
await page.getByTestId("swap-cities-button").click();
|
await page.getByTestId("swap-cities-button").click();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user