Fix first city click on flights map
ci-deploy / build-deploy-test (push) Successful in 2m12s

This commit is contained in:
2026-05-26 10:51:14 +03:00
parent a346aa071e
commit 5c309004f0
3 changed files with 198 additions and 0 deletions
@@ -40,12 +40,14 @@ interface MockMap {
_zoom: number;
_eventHandlers: Record<string, Array<() => void>>;
_addedLayers: Set<MockLayerGroup>;
_container: HTMLElement;
on: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
addLayer: ReturnType<typeof vi.fn>;
removeLayer: ReturnType<typeof vi.fn>;
hasLayer: ReturnType<typeof vi.fn>;
fitBounds: ReturnType<typeof vi.fn>;
getContainer: ReturnType<typeof vi.fn>;
getZoom: ReturnType<typeof vi.fn>;
setZoom: (z: number) => void;
fireZoomend: () => void;
@@ -139,10 +141,12 @@ vi.mock("leaflet", () => {
}
function mapFn(_container: unknown, _opts: unknown) {
const container = document.createElement("div");
const m: MockMap = {
_zoom: 5,
_eventHandlers: {},
_addedLayers: new Set<MockLayerGroup>(),
_container: container,
on: vi.fn((evt: string, fn: () => void) => {
m._eventHandlers[evt] ??= [];
m._eventHandlers[evt]!.push(fn);
@@ -167,6 +171,7 @@ vi.mock("leaflet", () => {
return owning ? m._addedLayers.has(owning) : false;
}),
fitBounds: vi.fn(() => m),
getContainer: vi.fn(() => container),
getZoom: vi.fn(() => m._zoom),
setZoom: (z: number) => {
m._zoom = z;
@@ -359,6 +364,27 @@ describe("MapCanvas — legacy (flat) path", () => {
expect(L.DomEvent.stop).toHaveBeenCalled();
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
});
it("selects marker icon clicks at the map container capture layer", () => {
const onMarkerClick = vi.fn();
renderCanvas(
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
{ onMarkerClick },
);
const map = createdMaps[0];
if (!map) {
throw new Error("expected map to be created");
}
const markerIcon = document.createElement("img");
markerIcon.className = "leaflet-marker-icon";
markerIcon.setAttribute("title", "MOW");
map._container.append(markerIcon);
markerIcon.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
});
});
describe("MapCanvas — categorized: visibility predicate", () => {
@@ -45,6 +45,16 @@ function getIcon(style: MarkerStyle): L.Icon {
return L.icon(opts);
}
function findMarkerIdFromEventTarget(target: EventTarget | null): string | undefined {
if (!(target instanceof Element)) return undefined;
const tooltip = target.closest<HTMLElement>(".city-label[data-marker-id]");
if (tooltip?.dataset.markerId) return tooltip.dataset.markerId;
const marker = target.closest<HTMLElement>(".leaflet-marker-icon[title]");
return marker?.getAttribute("title") ?? undefined;
}
// ---------------------------------------------------------------------------
// Polyline styles
// ---------------------------------------------------------------------------
@@ -325,6 +335,24 @@ export const MapCanvas: FC<MapCanvasProps> = ({
minZoom: minZoomRef.current,
}).addTo(map);
const container = map.getContainer();
const stopCityPointer = (event: Event) => {
const markerId = findMarkerIdFromEventTarget(event.target);
if (!markerId || !markerIndexRef.current.has(markerId)) return;
event.stopPropagation();
};
const selectCityFromDom = (event: Event) => {
const markerId = findMarkerIdFromEventTarget(event.target);
if (!markerId || !markerIndexRef.current.has(markerId)) return;
event.preventDefault();
event.stopPropagation();
onMarkerClickRef.current?.(markerId);
};
container.addEventListener("pointerdown", stopCityPointer, true);
container.addEventListener("mousedown", stopCityPointer, true);
container.addEventListener("touchstart", stopCityPointer, true);
container.addEventListener("click", selectCityFromDom, true);
markersLayerRef.current = L.layerGroup().addTo(map);
polylinesLayerRef.current = L.layerGroup().addTo(map);
popupsLayerRef.current = L.layerGroup().addTo(map);
@@ -356,6 +384,10 @@ export const MapCanvas: FC<MapCanvasProps> = ({
mapRef.current = map;
return () => {
container.removeEventListener("pointerdown", stopCityPointer, true);
container.removeEventListener("mousedown", stopCityPointer, true);
container.removeEventListener("touchstart", stopCityPointer, true);
container.removeEventListener("click", selectCityFromDom, true);
map.remove();
mapRef.current = null;
markersLayerRef.current = null;
@@ -407,9 +439,35 @@ export const MapCanvas: FC<MapCanvasProps> = ({
});
const tooltip = marker.getTooltip();
const attachTooltipDomHandlers = () => {
const element = tooltip?.getElement();
if (!element || element.dataset.markerClickBound === m.id) return;
element.dataset.markerClickBound = m.id;
element.dataset.markerId = m.id;
const stopTooltipPointerEvent = (event: Event) => {
event.stopPropagation();
};
const selectFromTooltip = (event: Event) => {
event.preventDefault();
event.stopPropagation();
onMarkerClickRef.current?.(m.id);
};
element.addEventListener("pointerdown", stopTooltipPointerEvent, true);
element.addEventListener("pointerup", stopTooltipPointerEvent, true);
element.addEventListener("mousedown", stopTooltipPointerEvent, true);
element.addEventListener("mouseup", stopTooltipPointerEvent, true);
element.addEventListener("touchstart", stopTooltipPointerEvent, true);
element.addEventListener("touchend", stopTooltipPointerEvent, true);
element.addEventListener("dblclick", stopTooltipPointerEvent, true);
element.addEventListener("click", selectFromTooltip, true);
};
const stopTooltipEvent = (event: L.LeafletMouseEvent) => {
L.DomEvent.stop(event);
};
marker.on("tooltipopen", attachTooltipDomHandlers);
tooltip?.on("mousedown", stopTooltipEvent);
tooltip?.on("mouseup", stopTooltipEvent);
tooltip?.on("dblclick", stopTooltipEvent);
+114
View File
@@ -102,6 +102,56 @@ async function routeFlightsMapDirectRouteFixtures(
return destinationRequests;
}
async function routeFlightsMapMoscowSpiderFixtures(
page: import("@playwright/test").Page,
): Promise<string[]> {
await routeAppSettingsFixture(page);
await routeDictionaryFixtures(page);
const destinationRequests: string[] = [];
await page.route("**/api/flights/1/*/destinations?**", async (route) => {
const url = new URL(route.request().url());
destinationRequests.push(url.toString());
const departure = url.searchParams.get("departure");
const arrival = url.searchParams.get("arrival");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
routes:
departure === "MOW" && !arrival
? [
{ route: ["SVO", "LED"], isDirect: true },
{ route: ["SVO", "MLE"], isDirect: true },
]
: [],
},
}),
});
});
await page.route("**/api/flights/v1/*/days/**/flights-map/", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ days: "1".repeat(200) }),
}),
);
await page.route("**/map/api/tile/**", (route) =>
route.fulfill({
status: 200,
contentType: "image/gif",
body: Buffer.from("R0lGODlhAQABAAAAACw=", "base64"),
}),
);
return destinationRequests;
}
async function selectCity(
page: import("@playwright/test").Page,
inputTestId: string,
@@ -157,6 +207,70 @@ test.describe("Flights Map", () => {
expect(consoleMessages).toEqual([]);
});
test("first click on a city label selects the city and draws spider routes", async ({
page,
consoleMessages,
}) => {
const destinationRequests = await routeFlightsMapMoscowSpiderFixtures(page);
await page.goto("/ru-ru/flights-map");
await expect(page.getByTestId("flights-map-start")).toBeVisible();
const moscowLabel = page.locator(".leaflet-tooltip.city-label", {
hasText: "Москва",
});
await expect(moscowLabel).toHaveCount(1, { timeout: 10000 });
await moscowLabel.click();
await expect(page.getByTestId("fm-departure-input").locator("input")).toHaveValue(
"Москва",
);
await expect(page.getByTestId("fm-arrival-input").locator("input")).toHaveValue("");
await expect
.poll(() =>
destinationRequests.some(
(url) =>
url.includes("departure=MOW") &&
!url.includes("arrival="),
),
)
.toBe(true);
await expect(page.locator("path.leaflet-interactive")).not.toHaveCount(0);
expect(consoleMessages).toEqual([]);
});
test("first click on a city marker selects the city", async ({
page,
consoleMessages,
}) => {
const destinationRequests = await routeFlightsMapMoscowSpiderFixtures(page);
await page.goto("/ru-ru/flights-map");
await expect(page.getByTestId("flights-map-start")).toBeVisible();
const moscowMarker = page.locator('img.leaflet-marker-icon[title="MOW"]');
await expect(moscowMarker).toHaveCount(1, { timeout: 10000 });
await moscowMarker.click();
await expect(page.getByTestId("fm-departure-input").locator("input")).toHaveValue(
"Москва",
);
await expect
.poll(() =>
destinationRequests.some(
(url) =>
url.includes("departure=MOW") &&
!url.includes("arrival="),
),
)
.toBe(true);
expect(consoleMessages).toEqual([]);
});
test("transfer-only checkbox stays off while a direct route search is still loading", async ({
page,
consoleMessages,