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 { FlightsMapFilter } from "./FlightsMapFilter.js";
|
||||||
import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js";
|
import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js";
|
||||||
import { useFlightsMapCalendar } from "../hooks/useFlightsMapCalendar.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 { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js";
|
||||||
import { filterRoutes } from "../filterRoutes.js";
|
import { filterRoutes } from "../filterRoutes.js";
|
||||||
import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.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>(
|
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
|
||||||
filterState.connections ? 1 : 0,
|
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