mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
fix: suid copy issue on safari (#6174)
This commit is contained in:
committed by
GitHub
parent
0c1f6f3c3a
commit
b0a7e212dd
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user