Migrate flights-map to shared useGeoCityDefault hook
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user