diff --git a/apps/web/modules/survey/list/components/survey-dropdown-menu.test.tsx b/apps/web/modules/survey/list/components/survey-dropdown-menu.test.tsx index 97c94c2a5b..a62635450a 100644 --- a/apps/web/modules/survey/list/components/survey-dropdown-menu.test.tsx +++ b/apps/web/modules/survey/list/components/survey-dropdown-menu.test.tsx @@ -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( { 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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( + + ); + + 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( { 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"]); }); }); }); diff --git a/apps/web/modules/survey/list/components/survey-dropdown-menu.tsx b/apps/web/modules/survey/list/components/survey-dropdown-menu.tsx index d6f2a3137a..bc15b33d4f 100644 --- a/apps/web/modules/survey/list/components/survey-dropdown-menu.tsx +++ b/apps/web/modules/survey/list/components/survey-dropdown-menu.tsx @@ -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(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")); } };