Migrate flights-map to shared useGeoCityDefault hook

This commit is contained in:
2026-04-21 19:01:07 +03:00
parent b31204c543
commit bc0b10bd8e
3 changed files with 11 additions and 239 deletions
@@ -18,7 +18,7 @@ import { ClientOnly } from "./ClientOnly.js";
import { FlightsMapFilter } from "./FlightsMapFilter.js";
import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js";
import { useFlightsMapCalendar } from "../hooks/useFlightsMapCalendar.js";
import { useGeolocationDefault } from "../hooks/useGeolocationDefault.js";
import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js";
import { filterRoutes } from "../filterRoutes.js";
import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js";
@@ -114,7 +114,16 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
};
});
useGeolocationDefault(dictionaries, filterState, setFilterState);
useGeoCityDefault({
dictionaries,
shouldApply: () => !filterState.departure && !filterState.arrival,
onCity: (cityCode) =>
setFilterState((prev) =>
prev.departure || prev.arrival
? prev
: { ...prev, departure: cityCode },
),
});
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
filterState.connections ? 1 : 0,
@@ -1,168 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { StrictMode, type ReactNode } from "react";
import { useGeolocationDefault } from "./useGeolocationDefault.js";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
import type { IFlightsMapFilterState } from "../types.js";
const raw: IRawDictionaries = {
regions: [],
countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }],
cities: [
{ code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } },
{ code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } },
],
airports: [
{ code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
{ code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } },
],
};
const dictionaries: IDictionaries = transformDictionaries(raw, "ru");
function makeFilter(
overrides: Partial<IFlightsMapFilterState> = {},
): IFlightsMapFilterState {
return {
connections: false,
domestic: false,
international: false,
...overrides,
};
}
interface GeolocationMock {
getCurrentPosition: ReturnType<typeof vi.fn>;
}
function installGeolocation(mock: GeolocationMock | undefined): void {
Object.defineProperty(navigator, "geolocation", {
value: mock,
writable: true,
configurable: true,
});
}
function successMock(lat: number, lon: number): GeolocationMock {
return {
getCurrentPosition: vi.fn((success: PositionCallback) => {
success({
coords: {
latitude: lat,
longitude: lon,
accuracy: 0,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({}),
},
timestamp: 0,
toJSON: () => ({}),
} as GeolocationPosition);
}),
};
}
function errorMock(): GeolocationMock {
return {
getCurrentPosition: vi.fn((_success, error?: PositionErrorCallback) => {
error?.({
code: 1,
message: "permission denied",
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3,
} as GeolocationPositionError);
}),
};
}
describe("useGeolocationDefault", () => {
let lastState: IFlightsMapFilterState = makeFilter();
let setFilterState: (updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState) => void;
beforeEach(() => {
lastState = makeFilter();
setFilterState = (updater) => {
lastState = updater(lastState);
};
});
afterEach(() => {
installGeolocation(undefined);
});
it("sets departure to nearest city when filter is empty and geolocation succeeds", () => {
installGeolocation(successMock(55.1, 37.1));
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
expect(lastState.departure).toBe("MOW");
});
it("does not update when filter.departure is already set", () => {
lastState = makeFilter({ departure: "LED" });
installGeolocation(successMock(55.1, 37.1));
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
expect(lastState.departure).toBe("LED");
});
it("does not update when filter.arrival is already set", () => {
lastState = makeFilter({ arrival: "MOW" });
installGeolocation(successMock(55.1, 37.1));
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
// Arrival pre-set blocks both the MOW fallback AND the geo result —
// arrival/departure pair would otherwise produce a same-city pair
// which has no map purpose.
expect(lastState.departure).toBeUndefined();
});
it("leaves departure empty when geolocation permission is denied", () => {
installGeolocation(errorMock());
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
// Angular's FlightsMapFilterComponent only seeds departure when
// UserLocationService.location emits — there's no fallback when
// the user denies geo access.
expect(lastState.departure).toBeUndefined();
});
it("leaves departure empty when navigator.geolocation is missing", () => {
installGeolocation(undefined);
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
expect(lastState.departure).toBeUndefined();
});
it("does nothing when dictionaries is null at callback time", () => {
installGeolocation(successMock(55.1, 37.1));
renderHook(() =>
useGeolocationDefault(null, lastState, setFilterState),
);
expect(lastState.departure).toBeUndefined();
});
it("invokes getCurrentPosition exactly once under StrictMode", () => {
const mock = successMock(55.1, 37.1);
installGeolocation(mock);
const wrapper = ({ children }: { children: ReactNode }) => (
<StrictMode>{children}</StrictMode>
);
renderHook(
() => useGeolocationDefault(dictionaries, lastState, setFilterState),
{ wrapper },
);
expect(mock.getCurrentPosition).toHaveBeenCalledTimes(1);
});
});
@@ -1,69 +0,0 @@
/**
* Sets the flights-map filter's departure to the nearest city based on
* browser geolocation, if the user has not already typed a departure or
* arrival. Fires once per mount. Silent on permission denial or missing
* geolocation API. Matches Angular `FlightsMapFilterComponent.ngOnInit`
* which only seeds departure when `UserLocationService.location` emits
* — i.e. on actual geo permission, with no MOW fallback.
*/
import { useEffect, useRef } from "react";
import {
findCityByCoord,
type IDictionaries,
} from "@/shared/dictionaries/index.js";
import type { IFlightsMapFilterState } from "../types.js";
export function useGeolocationDefault(
dictionaries: IDictionaries | null,
filterState: IFlightsMapFilterState,
setFilterState: (
updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState,
) => void,
): void {
const appliedRef = useRef(false);
const dictRef = useRef(dictionaries);
dictRef.current = dictionaries;
const filterRef = useRef(filterState);
filterRef.current = filterState;
const setFilterRef = useRef(setFilterState);
setFilterRef.current = setFilterState;
useEffect(() => {
if (appliedRef.current) return;
if (typeof navigator === "undefined" || !navigator.geolocation) {
appliedRef.current = true;
return;
}
appliedRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
const d = dictRef.current;
const f = filterRef.current;
if (!d) return;
if (f.departure || f.arrival) return;
const city = findCityByCoord(
d,
pos.coords.latitude,
pos.coords.longitude,
);
if (!city) return;
setFilterRef.current((prev) =>
prev.departure || prev.arrival
? prev
: { ...prev, departure: city.code },
);
},
() => {
// Silent: permission denied / timeout / unavailable. Matches
// Angular which leaves the filter empty when no location is
// available.
},
{ enableHighAccuracy: false, timeout: 5000 },
);
}, []);
}