diff --git a/src/components/map/suggest-place-dialog.test.tsx b/src/components/map/suggest-place-dialog.test.tsx new file mode 100644 index 0000000..6beea73 --- /dev/null +++ b/src/components/map/suggest-place-dialog.test.tsx @@ -0,0 +1,108 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + SuggestPlaceDialog, + isGoogleMapsUrl, +} from "@/components/map/suggest-place-dialog"; + +const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() })); + +vi.mock("sonner", () => ({ + toast: { error: toastError }, +})); + +afterEach(() => { + cleanup(); + toastError.mockReset(); + vi.restoreAllMocks(); +}); + +function fillRequiredFields(gmapsLink: string) { + fireEvent.change(screen.getByLabelText("Place name"), { + target: { value: "Central Library" }, + }); + fireEvent.change(screen.getByLabelText("City"), { + target: { value: "Mumbai" }, + }); + fireEvent.change(screen.getByLabelText("Google Maps link"), { + target: { value: gmapsLink }, + }); +} + +describe("isGoogleMapsUrl", () => { + it.each([ + "https://maps.google.com/?q=19.0176,72.8562", + "https://maps.app.goo.gl/abc123", + "https://www.google.com/maps/place/Central+Library", + "https://goo.gl/maps/abc123", + ])("accepts supported Google Maps URLs: %s", (url) => { + expect(isGoogleMapsUrl(url)).toBe(true); + }); + + it.each([ + "google maps com/place/x", + "http://maps.google.com/?q=19.0176,72.8562", + "https://maps.google.com.evil.example/place/x", + "https://example.com/maps/place/x", + ])("rejects malformed or non-Google Maps URLs: %s", (url) => { + expect(isGoogleMapsUrl(url)).toBe(false); + }); +}); + +describe("SuggestPlaceDialog submission", () => { + it("rejects an invalid Google Maps URL inline", () => { + render(); + fillRequiredFields("google maps com/place/x"); + + expect(screen.getByRole("alert").textContent).toContain( + "Enter a valid Google Maps link.", + ); + expect( + (screen.getByRole("button", { name: "Open GitHub issue" }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + it("keeps form data and exposes a fallback link when the popup is blocked", () => { + const onOpenChange = vi.fn(); + vi.spyOn(window, "open").mockReturnValue(null); + + render(); + fillRequiredFields("https://maps.app.goo.gl/abc123"); + fireEvent.click(screen.getByRole("button", { name: "Open GitHub issue" })); + + expect(window.open).toHaveBeenCalledWith("", "_blank"); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + expect((screen.getByLabelText("Place name") as HTMLInputElement).value).toBe( + "Central Library", + ); + expect((screen.getByLabelText("City") as HTMLInputElement).value).toBe("Mumbai"); + expect((screen.getByLabelText("Google Maps link") as HTMLInputElement).value).toBe( + "https://maps.app.goo.gl/abc123", + ); + + const fallback = screen.getByRole("link", { name: "Open GitHub here" }) as HTMLAnchorElement; + expect(fallback.href).toContain("github.com/StudentSuite/StudyMap/issues/new?"); + expect(fallback.rel).toBe("noopener noreferrer"); + expect(toastError).toHaveBeenCalledWith("Pop-up blocked. Your suggestion is still here."); + }); + + it("severs opener before navigating a successful popup and closes the dialog", () => { + const onOpenChange = vi.fn(); + const replace = vi.fn(); + const opened = { + opener: window, + location: { replace }, + } as unknown as Window; + vi.spyOn(window, "open").mockReturnValue(opened); + + render(); + fillRequiredFields("https://maps.google.com/?q=19.0176,72.8562"); + fireEvent.click(screen.getByRole("button", { name: "Open GitHub issue" })); + + expect(opened.opener).toBeNull(); + expect(replace).toHaveBeenCalledOnce(); + expect(replace.mock.calls[0]?.[0]).toContain("github.com/StudentSuite/StudyMap/issues/new?"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/map/suggest-place-dialog.tsx b/src/components/map/suggest-place-dialog.tsx index f6c12f8..95dc62c 100644 --- a/src/components/map/suggest-place-dialog.tsx +++ b/src/components/map/suggest-place-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -53,6 +54,26 @@ function buildIssueUrl(fields: { return `${site.repo}/issues/new?${params.toString()}`; } +export function isGoogleMapsUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:") return false; + + if (url.hostname === "maps.google.com") return true; + if (url.hostname === "maps.app.goo.gl") return url.pathname.length > 1; + if (url.hostname === "www.google.com") { + return url.pathname === "/maps" || url.pathname.startsWith("/maps/"); + } + if (url.hostname === "goo.gl") { + return url.pathname === "/maps" || url.pathname.startsWith("/maps/"); + } + + return false; + } catch { + return false; + } +} + /** * Public, no-account entry point for suggesting a new place: collects the * same fields as the GitHub-issue path in /docs/contributing, then opens a @@ -66,6 +87,7 @@ export function SuggestPlaceDialog({ open, onOpenChange }: SuggestPlaceDialogPro const [address, setAddress] = React.useState(""); const [gmapsLink, setGmapsLink] = React.useState(""); const [note, setNote] = React.useState(""); + const [popupBlocked, setPopupBlocked] = React.useState(false); const [lastResetKey, setLastResetKey] = React.useState(null); // Clear the form each time the dialog opens, during render (not an @@ -79,22 +101,43 @@ export function SuggestPlaceDialog({ open, onOpenChange }: SuggestPlaceDialogPro setAddress(""); setGmapsLink(""); setNote(""); + setPopupBlocked(false); } } - const isValid = name.trim() && city.trim() && gmapsLink.trim(); + const trimmedName = name.trim(); + const trimmedCity = city.trim(); + const trimmedGmapsLink = gmapsLink.trim(); + const isGmapsLinkValid = isGoogleMapsUrl(trimmedGmapsLink); + const showGmapsError = trimmedGmapsLink.length > 0 && !isGmapsLinkValid; + const isValid = Boolean(trimmedName && trimmedCity && isGmapsLinkValid); + const issueUrl = isValid + ? buildIssueUrl({ + name: trimmedName, + type, + city: trimmedCity, + address: address.trim(), + gmapsLink: trimmedGmapsLink, + note, + }) + : null; function handleSubmit() { - if (!isValid) return; - const url = buildIssueUrl({ - name: name.trim(), - type, - city: city.trim(), - address: address.trim(), - gmapsLink: gmapsLink.trim(), - note, - }); - window.open(url, "_blank", "noopener,noreferrer"); + if (!issueUrl) return; + + // Open a blank tab first so a non-null handle genuinely means the browser + // allowed the popup. Passing "noopener" to window.open intentionally + // returns null even when the tab opens. Sever opener before navigating. + const opened = window.open("", "_blank"); + if (!opened) { + setPopupBlocked(true); + toast.error("Pop-up blocked. Your suggestion is still here."); + return; + } + + opened.opener = null; + opened.location.replace(issueUrl); + setPopupBlocked(false); onOpenChange(false); } @@ -166,7 +209,14 @@ export function SuggestPlaceDialog({ open, onOpenChange }: SuggestPlaceDialogPro value={gmapsLink} onChange={(e) => setGmapsLink(e.target.value)} placeholder="https://maps.app.goo.gl/..." + aria-invalid={showGmapsError} + aria-describedby={showGmapsError ? "suggest-place-gmaps-error" : undefined} /> + {showGmapsError ? ( + + ) : null}
@@ -181,6 +231,21 @@ export function SuggestPlaceDialog({ open, onOpenChange }: SuggestPlaceDialogPro placeholder="Optional - why it belongs on the map, rating, review count..." />
+ + {popupBlocked && issueUrl ? ( +

+ Pop-up blocked. Your suggestion is still here.{" "} + + Open GitHub here + + . +

+ ) : null}