Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions src/components/map/places-map.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { PlacesMap } from "@/components/map/places-map";
import type { MapViewport } from "@/components/map/map-view";
import type { Place } from "@/lib/types";

const { renderMap } = vi.hoisted(() => ({
renderMap: vi.fn<
(props: {
places: Place[];
focusId: string | null;
initialViewport: MapViewport | null;
}) => null
>(() => null),
}));

vi.mock("next/dynamic", () => ({ default: () => renderMap }));
vi.mock("@/components/map/map-sheet", () => ({
MapSheet: () => null,
SHEET_SNAP_POINTS: [0.15, 0.48, 0.92],
}));
vi.mock("@/lib/supabase/client", () => ({ createClient: () => null }));

function place(overrides: Partial<Place> & Pick<Place, "id" | "name">): Place {
return {
type: "library",
city: "mumbai",
lat: 19.0176,
lng: 72.8562,
gmaps_link: "https://maps.google.com/?q=19.0176,72.8562",
added_by: "test",
...overrides,
};
}

const PLACES: Place[] = [
place({ id: "city-library", name: "City Library" }),
place({ id: "reading-room", name: "Riverside Reading Room" }),
place({ id: "airport", name: "City Airport", type: "airport" }),
place({ id: "thane-library", name: "Thane Library", city: "thane" }),
];
const FILTERED_URL =
"/map?types=library&city=mumbai&q=Library&place=city-library&lat=19.125&lng=72.375&zoom=11";
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const writeText = vi.fn<(text: string) => Promise<void>>();

function searchInputs() {
return screen.getAllByRole<HTMLInputElement>("searchbox", {
name: "Search places by name or city",
});
}

function advance(ms: number) {
act(() => {
vi.advanceTimersByTime(ms);
});
}

function params() {
return new URLSearchParams(window.location.search);
}

function expectExistingState() {
expect(params().get("types")).toBe("library");
expect(params().get("city")).toBe("mumbai");
expect(params().get("place")).toBe("city-library");
expect(params().get("lat")).toBe("19.125");
expect(params().get("lng")).toBe("72.375");
expect(params().get("zoom")).toBe("11");
}

beforeEach(() => {
vi.useFakeTimers();
window.history.replaceState(null, "", "/map");
renderMap.mockClear();
writeText.mockReset().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
});

afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
if (clipboardDescriptor) {
Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
});

describe("PlacesMap search URLs", () => {
it("keeps an initial query and the other shared state after mounting", () => {
window.history.replaceState(null, "", FILTERED_URL);
render(<PlacesMap places={PLACES} />);

expect(searchInputs().map((input) => input.value)).toEqual(["Library", "Library"]);
expect(screen.getByText("City Library")).toBeTruthy();
expect(screen.queryByText("Riverside Reading Room")).toBeNull();
expect(screen.queryByText("City Airport")).toBeNull();
expect(screen.queryByText("Thane Library")).toBeNull();
expect(params().get("q")).toBe("Library");
expectExistingState();
expect(
screen.getByRole("button", { name: /^Library\s*1$/ }).getAttribute("aria-pressed"),
).toBe("true");
expect(screen.getByRole("combobox").textContent).toContain("Mumbai");
expect(renderMap.mock.lastCall?.[0]).toMatchObject({
places: [PLACES[0]],
focusId: "city-library",
initialViewport: { lat: 19.125, lng: 72.375, zoom: 11 },
});
});

it("updates results and the URL only after the latest input has settled", () => {
render(<PlacesMap places={PLACES} />);
const replace = vi.spyOn(window.history, "replaceState");
const historyLength = window.history.length;
const [desktop, mobile] = searchInputs();

fireEvent.change(desktop, { target: { value: "Library" } });
advance(200);
fireEvent.change(mobile, { target: { value: "Reading" } });
advance(249);

expect(searchInputs().map((input) => input.value)).toEqual(["Reading", "Reading"]);
expect(screen.getByText("City Library")).toBeTruthy();
expect(screen.getByText("Riverside Reading Room")).toBeTruthy();
expect(screen.getByText("City Airport")).toBeTruthy();
expect(screen.getByText("Thane Library")).toBeTruthy();
expect(params().has("q")).toBe(false);
expect(replace).not.toHaveBeenCalled();

advance(1);

expect(screen.queryByText("City Library")).toBeNull();
expect(screen.getByText("Riverside Reading Room")).toBeTruthy();
expect(screen.queryByText("City Airport")).toBeNull();
expect(screen.queryByText("Thane Library")).toBeNull();
expect(params().get("q")).toBe("Reading");
expect(replace).toHaveBeenCalledOnce();
expect(window.history.length).toBe(historyLength);
});

it("shares the applied query and restores the same results from the copied link", async () => {
window.history.replaceState(null, "", FILTERED_URL);
const view = render(<PlacesMap places={PLACES} />);
fireEvent.change(searchInputs()[0], { target: { value: "Reading" } });

await act(async () => {
fireEvent.click(
screen.getByRole("button", { name: "Copy a shareable link to this view" }),
);
});
expect(new URL(writeText.mock.calls[0][0]).searchParams.get("q")).toBe("Library");
expect(screen.getByText("City Library")).toBeTruthy();

advance(250);
await act(async () => {
fireEvent.click(
screen.getByRole("button", { name: "Copy a shareable link to this view" }),
);
});
const copied = new URL(writeText.mock.calls[1][0]);
expect(copied.searchParams.get("q")).toBe("Reading");
expect(copied.pathname).toBe("/map");
view.unmount();
window.history.replaceState(null, "", copied.href);
render(<PlacesMap places={PLACES} />);

expect(searchInputs().map((input) => input.value)).toEqual(["Reading", "Reading"]);
expect(screen.getByText("Riverside Reading Room")).toBeTruthy();
expect(screen.queryByText("City Library")).toBeNull();
expect(params().get("q")).toBe("Reading");
expectExistingState();
});

it("clears the query without waiting 250 ms or losing the other shared state", () => {
window.history.replaceState(null, "", FILTERED_URL);
render(<PlacesMap places={PLACES} />);

fireEvent.change(searchInputs()[0], { target: { value: "" } });
advance(0);

expect(searchInputs().every((input) => input.value === "")).toBe(true);
expect(params().has("q")).toBe(false);
expectExistingState();
expect(screen.getByText("City Library")).toBeTruthy();
expect(screen.getByText("Riverside Reading Room")).toBeTruthy();
expect(screen.queryByText("City Airport")).toBeNull();
expect(screen.queryByText("Thane Library")).toBeNull();
});

it("resets the search and filters to an unfiltered URL", () => {
window.history.replaceState(null, "", "/map?q=Library&types=library&city=mumbai");
render(<PlacesMap places={PLACES} />);

fireEvent.click(screen.getByRole("button", { name: "Reset" }));
advance(0);

expect(window.location.search).toBe("");
expect(searchInputs().every((input) => input.value === "")).toBe(true);
for (const item of PLACES) {
expect(screen.getByText(item.name)).toBeTruthy();
}
});
});
12 changes: 5 additions & 7 deletions src/components/map/places-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,11 @@ export function PlacesMap({ places }: PlacesMapProps) {
const [filters, setFilters] = React.useState<PlaceFilters>(() => {
if (typeof window === "undefined") return { types: [], city: null, query: "" };
const state = parseMapState(window.location.search);
// "q" is a plain search-term param (distinct from the filter state
// mirrored via replaceState below): it's how the nav search's "see all
// places" link and any other direct link hand off a free-text query.
const q = new URLSearchParams(window.location.search).get("q") ?? "";
return { types: state.types, city: state.city, query: q };
return { types: state.types, city: state.city, query: state.query };
});
const [debouncedQuery, setDebouncedQuery] = React.useState(() => {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("q") ?? "";
return parseMapState(window.location.search).query;
});
const [focusId, setFocusId] = React.useState<string | null>(() => {
if (typeof window === "undefined") return null;
Expand Down Expand Up @@ -184,13 +180,14 @@ export function PlacesMap({ places }: PlacesMapProps) {
const search = mapStateToSearch({
types: filters.types,
city: filters.city,
query: debouncedQuery,
placeId: focusId,
lat: viewport?.lat ?? null,
lng: viewport?.lng ?? null,
zoom: viewport?.zoom ?? null,
});
window.history.replaceState(null, "", `${window.location.pathname}${search}`);
}, [filters, focusId, viewport]);
}, [filters.types, filters.city, debouncedQuery, focusId, viewport]);

const visible = React.useMemo(
() =>
Expand Down Expand Up @@ -263,6 +260,7 @@ export function PlacesMap({ places }: PlacesMapProps) {
const url = buildShareUrl({
types: filters.types,
city: filters.city,
query: debouncedQuery,
placeId: focusId,
lat: viewport?.lat ?? null,
lng: viewport?.lng ?? null,
Expand Down
1 change: 1 addition & 0 deletions src/components/pins/pin-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function PinPopup({ place }: PinPopupProps) {
const url = buildShareUrl({
types: [],
city: null,
query: "",
placeId: place.id,
lat: place.lat,
lng: place.lng,
Expand Down
30 changes: 30 additions & 0 deletions src/lib/share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
const emptyState: MapShareState = {
types: [],
city: null,
query: "",
placeId: null,
lat: null,
lng: null,
Expand All @@ -21,6 +22,7 @@ describe("mapStateToSearch / parseMapState round-trip", () => {
const state: MapShareState = {
types: ["library", "sat_centre"],
city: "mumbai",
query: "library",
placeId: "mum-library-01",
lat: 19.076,
lng: 72.8777,
Expand All @@ -35,6 +37,27 @@ describe("mapStateToSearch / parseMapState round-trip", () => {
expect(parseMapState("")).toEqual(emptyState);
});

it("reads a query supplied by a direct map link", () => {
expect(parseMapState("?q=library")).toEqual({
...emptyState,
query: "library",
});
});

it.each(["", "library", "C++ & books #1? 50%", "図書館 café", " library ", " "])(
"round-trips the search query %j without changing its text",
(query) => {
const search = mapStateToSearch({ ...emptyState, query });
expect(new URLSearchParams(search).get("q")).toBe(query || null);
expect(parseMapState(search)).toEqual({ ...emptyState, query });
},
);

it("treats an explicitly empty query like an absent query", () => {
expect(parseMapState("?q=")).toEqual(emptyState);
expect(mapStateToSearch(parseMapState("?q="))).toBe("");
});

it("drops unknown place types when parsing", () => {
const parsed = parseMapState("?types=library,not_a_real_type");
expect(parsed.types).toEqual(["library"]);
Expand Down Expand Up @@ -111,6 +134,13 @@ describe("mapStateToSearch / parseMapState round-trip", () => {
});

describe("buildShareUrl", () => {
it("includes an encoded search query in the copied link", () => {
const query = "C++ & 図書館";
const url = new URL(buildShareUrl({ ...emptyState, query }));
expect(url.searchParams.get("q")).toBe(query);
expect(parseMapState(url.search).query).toBe(query);
});

it("builds an absolute URL from the current origin and pathname", () => {
const url = buildShareUrl({
...emptyState,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { City, PlaceType } from "@/lib/types";
export interface MapShareState {
types: PlaceType[];
city: City | null;
query: string;
placeId: string | null;
/** Optional viewport: map center coordinates and zoom level. */
lat: number | null;
Expand Down Expand Up @@ -42,6 +43,7 @@ export function parseMapState(search: string): MapShareState {
return {
types: parseList(params.get("types"), TYPE_SET) as PlaceType[],
city,
query: params.get("q") ?? "",
placeId: params.get("place"),
lat: hasViewport ? lat : null,
lng: hasViewport ? lng : null,
Expand All @@ -54,6 +56,7 @@ export function mapStateToSearch(state: MapShareState): string {
const params = new URLSearchParams();
if (state.types.length) params.set("types", state.types.join(","));
if (state.city) params.set("city", state.city);
if (state.query) params.set("q", state.query);
if (state.placeId) params.set("place", state.placeId);
if (state.lat !== null && state.lng !== null) {
params.set("lat", String(state.lat));
Expand Down
Loading