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
108 changes: 108 additions & 0 deletions src/components/map/suggest-place-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SuggestPlaceDialog open onOpenChange={vi.fn()} />);
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(<SuggestPlaceDialog open onOpenChange={onOpenChange} />);
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(<SuggestPlaceDialog open onOpenChange={onOpenChange} />);
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);
});
});
87 changes: 76 additions & 11 deletions src/components/map/suggest-place-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import * as React from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
Expand Down Expand Up @@ -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
Expand All @@ -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<boolean | null>(null);

// Clear the form each time the dialog opens, during render (not an
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 ? (
<p id="suggest-place-gmaps-error" role="alert" className="text-xs text-destructive">
Enter a valid Google Maps link.
</p>
) : null}
</div>

<div className="grid gap-1.5">
Expand All @@ -181,6 +231,21 @@ export function SuggestPlaceDialog({ open, onOpenChange }: SuggestPlaceDialogPro
placeholder="Optional - why it belongs on the map, rating, review count..."
/>
</div>

{popupBlocked && issueUrl ? (
<p role="alert" className="text-sm text-destructive">
Pop-up blocked. Your suggestion is still here.{" "}
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Open GitHub here
</a>
.
</p>
) : null}
</div>

<DialogFooter>
Expand Down
Loading