diff --git a/src/components/map/places-map.test.tsx b/src/components/map/places-map.test.tsx new file mode 100644 index 0000000..24db279 --- /dev/null +++ b/src/components/map/places-map.test.tsx @@ -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 & Pick): 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>(); + +function searchInputs() { + return screen.getAllByRole("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(); + + 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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + } + }); +}); diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index 6940b06..b646a26 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -56,15 +56,11 @@ export function PlacesMap({ places }: PlacesMapProps) { const [filters, setFilters] = React.useState(() => { 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(() => { if (typeof window === "undefined") return null; @@ -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( () => @@ -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, diff --git a/src/components/pins/pin-popup.tsx b/src/components/pins/pin-popup.tsx index e844693..ccfa36e 100644 --- a/src/components/pins/pin-popup.tsx +++ b/src/components/pins/pin-popup.tsx @@ -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, diff --git a/src/lib/share.test.ts b/src/lib/share.test.ts index 685f5ed..c182f98 100644 --- a/src/lib/share.test.ts +++ b/src/lib/share.test.ts @@ -10,6 +10,7 @@ import { const emptyState: MapShareState = { types: [], city: null, + query: "", placeId: null, lat: null, lng: null, @@ -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, @@ -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"]); @@ -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, diff --git a/src/lib/share.ts b/src/lib/share.ts index 0ffa268..9207ff4 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -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; @@ -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, @@ -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));