fix: suid copy issue on safari (#6174)

This commit is contained in:
Dhruwang Jariwala
2025-07-08 16:20:02 +05:30
committed by GitHub
parent 0c1f6f3c3a
commit b0a7e212dd
2 changed files with 158 additions and 9 deletions

View File

@@ -70,6 +70,14 @@ vi.mock("react-hot-toast", () => ({
},
}));
// Mock clipboard API
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: vi.fn(),
},
writable: true,
});
describe("SurveyDropDownMenu", () => {
afterEach(() => {
cleanup();
@@ -78,7 +86,6 @@ describe("SurveyDropDownMenu", () => {
test("calls copySurveyLink when copy link is clicked", async () => {
const mockRefresh = vi.fn().mockResolvedValue("fakeSingleUseId");
const mockDeleteSurvey = vi.fn();
const mockDuplicateSurvey = vi.fn();
render(
<SurveyDropDownMenu
@@ -149,6 +156,135 @@ describe("SurveyDropDownMenu", () => {
responseCount: 5,
} as unknown as TSurvey;
describe("clipboard functionality", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("pre-fetches single-use ID when dropdown opens", async () => {
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, status: "completed" }}
publicDomain="http://survey.test"
refreshSingleUseId={mockRefreshSingleUseId}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
// Initially, refreshSingleUseId should not have been called
expect(mockRefreshSingleUseId).not.toHaveBeenCalled();
// Open dropdown
await userEvent.click(triggerElement);
// Now it should have been called
await waitFor(() => {
expect(mockRefreshSingleUseId).toHaveBeenCalledTimes(1);
});
});
test("does not pre-fetch single-use ID when dropdown is closed", async () => {
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, status: "completed" }}
publicDomain="http://survey.test"
refreshSingleUseId={mockRefreshSingleUseId}
deleteSurvey={vi.fn()}
/>
);
// Don't open dropdown
// Wait a bit to ensure useEffect doesn't run
await waitFor(() => {
expect(mockRefreshSingleUseId).not.toHaveBeenCalled();
});
});
test("copies link with pre-fetched single-use ID", async () => {
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
const mockWriteText = vi.fn().mockResolvedValue(undefined);
navigator.clipboard.writeText = mockWriteText;
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, status: "completed" }}
publicDomain="http://survey.test"
refreshSingleUseId={mockRefreshSingleUseId}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
// Open dropdown to trigger pre-fetch
await userEvent.click(triggerElement);
// Wait for pre-fetch to complete
await waitFor(() => {
expect(mockRefreshSingleUseId).toHaveBeenCalled();
});
// Click copy link
const copyLinkButton = screen.getByTestId("copy-link");
await userEvent.click(copyLinkButton);
// Verify clipboard was called with the correct URL including single-use ID
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith("http://survey.test/s/testSurvey?suId=test-single-use-id");
expect(mockToast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
test("handles copy link with undefined single-use ID", async () => {
const mockRefreshSingleUseId = vi.fn().mockResolvedValue(undefined);
const mockWriteText = vi.fn().mockResolvedValue(undefined);
navigator.clipboard.writeText = mockWriteText;
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, status: "completed" }}
publicDomain="http://survey.test"
refreshSingleUseId={mockRefreshSingleUseId}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
// Open dropdown to trigger pre-fetch
await userEvent.click(triggerElement);
// Wait for pre-fetch to complete
await waitFor(() => {
expect(mockRefreshSingleUseId).toHaveBeenCalled();
});
// Click copy link
const copyLinkButton = screen.getByTestId("copy-link");
await userEvent.click(copyLinkButton);
// Verify clipboard was called with base URL (no single-use ID)
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith("http://survey.test/s/testSurvey");
expect(mockToast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
});
test("handleEditforActiveSurvey opens EditPublicSurveyAlertDialog for active surveys", async () => {
render(
<SurveyDropDownMenu
@@ -285,7 +421,6 @@ describe("SurveyDropDownMenu", () => {
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
expect(mockDeleteSurvey).toHaveBeenCalledWith("testSurvey");
expect(mockToast.success).toHaveBeenCalledWith("environments.surveys.survey_deleted_successfully");
expect(mockRouterRefresh).toHaveBeenCalled();
});
});
@@ -396,7 +531,6 @@ describe("SurveyDropDownMenu", () => {
// Verify that deleteSurvey callback was not called due to error
expect(mockDeleteSurvey).not.toHaveBeenCalled();
expect(mockRouterRefresh).not.toHaveBeenCalled();
});
test("does not call router.refresh or success toast when deleteSurveyAction throws", async () => {
@@ -480,7 +614,7 @@ describe("SurveyDropDownMenu", () => {
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success", "router.refresh"]);
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success"]);
});
});
});

View File

@@ -30,8 +30,9 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { logger } from "@formbricks/logger";
import { CopySurveyModal } from "./copy-survey-modal";
interface SurveyDropDownMenuProps {
@@ -61,18 +62,33 @@ export const SurveyDropDownMenu = ({
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const [newSingleUseId, setNewSingleUseId] = useState<string | undefined>(undefined);
const router = useRouter();
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
// Pre-fetch single-use ID when dropdown opens to avoid async delay during clipboard operation
// This ensures Safari's clipboard API works by maintaining the user gesture context
useEffect(() => {
if (!isDropDownOpen) return;
const fetchNewId = async () => {
try {
const newId = await refreshSingleUseId();
setNewSingleUseId(newId ?? undefined);
} catch (error) {
logger.error(error);
}
};
fetchNewId();
}, [refreshSingleUseId, isDropDownOpen]);
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
try {
await deleteSurveyAction({ surveyId });
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
router.refresh();
} catch (error) {
toast.error(t("environments.surveys.error_deleting_survey"));
} finally {
@@ -84,12 +100,11 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
const newId = await refreshSingleUseId();
const copiedLink = copySurveyLink(surveyLink, newId);
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
router.refresh();
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
}
};