diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx index 9f15fb93da..e1d407eb56 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx @@ -40,7 +40,7 @@ vi.mock("@tolgee/react", () => ({ // Mock Next.js hooks const mockPush = vi.fn(); -const mockPathname = "/environments/env-id/surveys/survey-id/summary"; +const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary"; const mockSearchParams = new URLSearchParams(); vi.mock("next/navigation", () => ({ @@ -69,6 +69,14 @@ vi.mock("@/modules/survey/list/actions", () => ({ copySurveyToOtherEnvironmentAction: vi.fn(), })); +// Mock the useSingleUseId hook +vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({ + useSingleUseId: vi.fn(() => ({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"), + })), +})); + // Mock child components vi.mock( "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage", @@ -434,4 +442,328 @@ describe("SurveyAnalysisCTA", () => { expect(screen.getByTestId("success-message")).toBeInTheDocument(); expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); }); + + test("duplicates survey when primary button is clicked in edit dialog", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + mockCopySurveyAction.mockResolvedValue({ + data: { + ...mockSurvey, + id: "new-survey-id", + environmentId: "test-env-id", + triggers: [], + segment: null, + resultShareKey: null, + languages: [], + }, + }); + + const toast = await import("react-hot-toast"); + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + expect(mockCopySurveyAction).toHaveBeenCalledWith({ + environmentId: "test-env-id", + surveyId: "test-survey-id", + targetEnvironmentId: "test-env-id", + }); + expect(toast.default.success).toHaveBeenCalledWith("Survey duplicated successfully"); + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/new-survey-id/edit"); + }); + + test("handles error when duplicating survey fails", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + mockCopySurveyAction.mockResolvedValue({ + data: undefined, + serverError: "Duplication failed", + validationErrors: undefined, + bindArgsValidationErrors: [], + }); + + const toast = await import("react-hot-toast"); + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + expect(toast.default.error).toHaveBeenCalledWith("Error message"); + }); + + test("navigates to edit when secondary button is clicked in edit dialog", async () => { + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click secondary button (edit) + await user.click(screen.getByTestId("secondary-button")); + + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit"); + }); + + test("shows loading state during duplication", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + + // Mock a delayed response + mockCopySurveyAction.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: { + ...mockSurvey, + id: "new-survey-id", + environmentId: "test-env-id", + triggers: [], + segment: null, + resultShareKey: null, + languages: [], + }, + }), + 100 + ) + ) + ); + + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + // Check loading state + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-loading", "true"); + }); + + test("closes dialog after successful duplication", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + mockCopySurveyAction.mockResolvedValue({ + data: { + ...mockSurvey, + id: "new-survey-id", + environmentId: "test-env-id", + triggers: [], + segment: null, + resultShareKey: null, + languages: [], + }, + }); + + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true"); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + // Dialog should be closed + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "false"); + }); + + test("opens preview with single use ID when enabled", async () => { + const mockUseSingleUseId = vi.mocked( + await import("@/modules/survey/hooks/useSingleUseId") + ).useSingleUseId; + mockUseSingleUseId.mockReturnValue({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue("new-single-use-id"), + }); + + const surveyWithSingleUse = { + ...mockSurvey, + type: "link" as const, + singleUse: { enabled: true, isEncrypted: false }, + }; + + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId("icon-bar-action-1")); + + expect(windowOpenSpy).toHaveBeenCalledWith( + "https://example.com/s/test-survey-id?suId=new-single-use-id&preview=true", + "_blank" + ); + windowOpenSpy.mockRestore(); + }); + + test("handles single use ID generation failure", async () => { + const mockUseSingleUseId = vi.mocked( + await import("@/modules/survey/hooks/useSingleUseId") + ).useSingleUseId; + mockUseSingleUseId.mockReturnValue({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue(undefined), + }); + + const surveyWithSingleUse = { + ...mockSurvey, + type: "link" as const, + singleUse: { enabled: true, isEncrypted: false }, + }; + + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId("icon-bar-action-1")); + + expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank"); + windowOpenSpy.mockRestore(); + }); + + test("opens share modal with correct modal view when share button clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Share survey")); + + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share"); + }); + + test("handles different survey statuses correctly", () => { + const completedSurvey = { ...mockSurvey, status: "completed" as const }; + render(); + + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("handles paused survey status", () => { + const pausedSurvey = { ...mockSurvey, status: "paused" as const }; + render(); + + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("does not render share modal when user is null", () => { + render(); + + expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument(); + }); + + test("renders with different isFormbricksCloud values", () => { + const { rerender } = render(); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + }); + + test("renders with different isContactsEnabled values", () => { + const { rerender } = render(); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + }); + + test("handles app survey type", () => { + const appSurvey = { ...mockSurvey, type: "app" as const }; + render(); + + // Should not show preview icon for app surveys + expect(screen.queryByTestId("icon-bar-action-1")).toBeInTheDocument(); // This should be edit button + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit"); + }); + + test("handles modal state changes correctly", async () => { + const user = userEvent.setup(); + render(); + + // Open modal via share button + await user.click(screen.getByText("Share survey")); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + + // Close modal + await user.click(screen.getByText("Close Modal")); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false"); + }); + + test("opens share modal via share button", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Share survey")); + + // Should open the modal with share view + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share"); + }); + + test("closes share modal and updates modal state", async () => { + mockSearchParams.set("share", "true"); + const user = userEvent.setup(); + render(); + + // Modal should be open initially due to share param + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + + await user.click(screen.getByText("Close Modal")); + + // Should close the modal + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false"); + }); + + test("handles empty segments array", () => { + render(); + + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + }); + + test("handles zero response count", () => { + render(); + + expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument(); + }); + + test("shows all icon actions for non-readonly app survey", () => { + render(); + + // Should show bell (notifications) and edit actions + expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts"); + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit"); + }); + + test("shows all icon actions for non-readonly link survey", () => { + const linkSurvey = { ...mockSurvey, type: "link" as const }; + render(); + + // Should show bell (notifications), preview, and edit actions + expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts"); + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview"); + expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit"); + }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index 9a76aae39b..57d4917444 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -5,6 +5,7 @@ import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surve import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; +import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions"; import { Badge } from "@/modules/ui/components/badge"; import { Button } from "@/modules/ui/components/button"; @@ -12,7 +13,7 @@ import { IconBar } from "@/modules/ui/components/iconbar"; import { useTranslate } from "@tolgee/react"; import { BellRing, Eye, SquarePenIcon } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { TEnvironment } from "@formbricks/types/environment"; import { TSegment } from "@formbricks/types/segment"; @@ -48,17 +49,16 @@ export const SurveyAnalysisCTA = ({ isFormbricksCloud, }: SurveyAnalysisCTAProps) => { const { t } = useTranslate(); - const searchParams = useSearchParams(); - const pathname = usePathname(); const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const [loading, setLoading] = useState(false); - const [modalState, setModalState] = useState({ start: searchParams.get("share") === "true", share: false, }); - const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]); + const { refreshSingleUseId } = useSingleUseId(survey); const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted; @@ -102,9 +102,18 @@ export const SurveyAnalysisCTA = ({ setLoading(false); }; - const getPreviewUrl = () => { - const separator = surveyUrl.includes("?") ? "&" : "?"; - return `${surveyUrl}${separator}preview=true`; + const getPreviewUrl = async () => { + const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`); + + if (survey.singleUse?.enabled) { + const newId = await refreshSingleUseId(); + if (newId) { + surveyUrl.searchParams.set("suId", newId); + } + } + + surveyUrl.searchParams.set("preview", "true"); + return surveyUrl.toString(); }; const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false); @@ -119,7 +128,10 @@ export const SurveyAnalysisCTA = ({ { icon: Eye, tooltip: t("common.preview"), - onClick: () => window.open(getPreviewUrl(), "_blank"), + onClick: async () => { + const previewUrl = await getPreviewUrl(); + window.open(previewUrl, "_blank"); + }, isVisible: survey.type === "link", }, { diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index b6fcb3546e..347dc36efb 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1280,8 +1280,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.", "back_button_label": "Zurück\"- Button ", "background_styling": "Hintergründe", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blockiert die Umfrage, wenn bereits eine Antwort mit der Single Use Id (suId) existiert.", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blockiert Umfrage, wenn die Umfrage-URL keine Single-Use-ID (suId) hat.", "brand_color": "Markenfarbe", "brightness": "Helligkeit", "button_label": "Beschriftung", @@ -1366,7 +1364,6 @@ "does_not_start_with": "Fängt nicht an mit", "edit_recall": "Erinnerung bearbeiten", "edit_translations": "{lang} -Übersetzungen bearbeiten", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Single Use Id (suId) in der Umfrage-URL verschlüsseln.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.", "enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.", "enable_spam_protection": "Spamschutz", @@ -1442,7 +1439,6 @@ "hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken", "hostname": "Hostname", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein", - "how_it_works": "Wie es funktioniert", "if_you_need_more_please": "Wenn Du mehr brauchst, bitte", "if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.", "ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren", @@ -1480,7 +1476,6 @@ "limit_the_maximum_file_size": "Maximale Dateigröße begrenzen", "limit_upload_file_size_to": "Maximale Dateigröße für Uploads", "link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.", - "link_used_message": "Link verwendet", "load_segment": "Segment laden", "logic_error_warning": "Änderungen werden zu Logikfehlern führen", "logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage", @@ -1572,8 +1567,6 @@ "show_survey_to_users": "Umfrage % der Nutzer anzeigen", "show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer", "simple": "Einfach", - "single_use_survey_links": "Einmalige Umfragelinks", - "single_use_survey_links_description": "Erlaube nur eine Antwort pro Umfragelink.", "six_points": "6 Punkte", "skip_button_label": "Überspringen-Button-Beschriftung", "smiley": "Smiley", @@ -1590,8 +1583,6 @@ "subheading": "Zwischenüberschrift", "subtract": "Subtrahieren -", "suggest_colors": "Farben vorschlagen", - "survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.", - "survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.", "survey_completed_heading": "Umfrage abgeschlossen", "survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen", "survey_display_settings": "Einstellungen zur Anzeige der Umfrage", @@ -1622,7 +1613,6 @@ "upload": "Hochladen", "upload_at_least_2_images": "Lade mindestens 2 Bilder hoch", "upper_label": "Oberes Label", - "url_encryption": "URL-Verschlüsselung", "url_filters": "URL-Filter", "url_not_supported": "URL nicht unterstützt", "use_with_caution": "Mit Vorsicht verwenden", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index f517bd4a6e..62ef81abab 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1280,8 +1280,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).", "back_button_label": "\"Back\" Button Label", "background_styling": "Background Styling", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blocks survey if a submission with the Single Use Id (suId) exists already.", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blocks survey if the survey URL has no Single Use Id (suId).", "brand_color": "Brand color", "brightness": "Brightness", "button_label": "Button Label", @@ -1366,7 +1364,6 @@ "does_not_start_with": "Does not start with", "edit_recall": "Edit Recall", "edit_translations": "Edit {lang} translations", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Enable encryption of Single Use Id (suId) in survey URL.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.", "enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.", "enable_spam_protection": "Spam protection", @@ -1442,7 +1439,6 @@ "hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey", "hostname": "Hostname", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys", - "how_it_works": "How it works", "if_you_need_more_please": "If you need more, please", "if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.", "ignore_waiting_time_between_surveys": "Ignore waiting time between surveys", @@ -1480,7 +1476,6 @@ "limit_the_maximum_file_size": "Limit the maximum file size", "limit_upload_file_size_to": "Limit upload file size to", "link_survey_description": "Share a link to a survey page or embed it in a web page or email.", - "link_used_message": "Link Used", "load_segment": "Load segment", "logic_error_warning": "Changing will cause logic errors", "logic_error_warning_text": "Changing the question type will remove the logic conditions from this question", @@ -1572,8 +1567,6 @@ "show_survey_to_users": "Show survey to % of users", "show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users", "simple": "Simple", - "single_use_survey_links": "Single-use survey links", - "single_use_survey_links_description": "Allow only 1 response per survey link.", "six_points": "6 points", "skip_button_label": "Skip Button Label", "smiley": "Smiley", @@ -1590,8 +1583,6 @@ "subheading": "Subheading", "subtract": "Subtract -", "suggest_colors": "Suggest colors", - "survey_already_answered_heading": "The survey has already been answered.", - "survey_already_answered_subheading": "You can only use this link once.", "survey_completed_heading": "Survey Completed", "survey_completed_subheading": "This free & open-source survey has been closed", "survey_display_settings": "Survey Display Settings", @@ -1622,7 +1613,6 @@ "upload": "Upload", "upload_at_least_2_images": "Upload at least 2 images", "upper_label": "Upper Label", - "url_encryption": "URL Encryption", "url_filters": "URL Filters", "url_not_supported": "URL not supported", "use_with_caution": "Use with caution", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 61dce3de41..d64bcf06b1 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1280,8 +1280,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).", "back_button_label": "Label du bouton \"Retour''", "background_styling": "Style de fond", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloque les enquêtes si une soumission avec l'Identifiant à Usage Unique (suId) existe déjà.", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloque les enquêtes si l'URL de l'enquête n'a pas d'Identifiant d'Utilisation Unique (suId).", "brand_color": "Couleur de marque", "brightness": "Luminosité", "button_label": "Label du bouton", @@ -1366,7 +1364,6 @@ "does_not_start_with": "Ne commence pas par", "edit_recall": "Modifier le rappel", "edit_translations": "Modifier les traductions {lang}", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Activer le chiffrement de l'identifiant à usage unique (suId) dans l'URL de l'enquête.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.", "enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.", "enable_spam_protection": "Protection contre le spam", @@ -1442,7 +1439,6 @@ "hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique", "hostname": "Nom d'hôte", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}", - "how_it_works": "Comment ça fonctionne", "if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît", "if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.", "ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes", @@ -1480,7 +1476,6 @@ "limit_the_maximum_file_size": "Limiter la taille maximale du fichier", "limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à", "link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.", - "link_used_message": "Lien utilisé", "load_segment": "Segment de chargement", "logic_error_warning": "Changer causera des erreurs logiques", "logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.", @@ -1572,8 +1567,6 @@ "show_survey_to_users": "Afficher l'enquête à % des utilisateurs", "show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés", "simple": "Simple", - "single_use_survey_links": "Liens d'enquête à usage unique", - "single_use_survey_links_description": "Autoriser uniquement 1 réponse par lien d'enquête.", "six_points": "6 points", "skip_button_label": "Étiquette du bouton Ignorer", "smiley": "Sourire", @@ -1590,8 +1583,6 @@ "subheading": "Sous-titre", "subtract": "Soustraire -", "suggest_colors": "Suggérer des couleurs", - "survey_already_answered_heading": "L'enquête a déjà été répondue.", - "survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.", "survey_completed_heading": "Enquête terminée", "survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée", "survey_display_settings": "Paramètres d'affichage de l'enquête", @@ -1622,7 +1613,6 @@ "upload": "Télécharger", "upload_at_least_2_images": "Téléchargez au moins 2 images", "upper_label": "Étiquette supérieure", - "url_encryption": "Chiffrement d'URL", "url_filters": "Filtres d'URL", "url_not_supported": "URL non supportée", "use_with_caution": "À utiliser avec précaution", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 1b27702fd7..c0af12e582 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1280,8 +1280,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).", "back_button_label": "Voltar", "background_styling": "Estilo de Fundo", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia a pesquisa se já existir uma submissão com o Id de Uso Único (suId).", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia a pesquisa se a URL da pesquisa não tiver um Id de Uso Único (suId).", "brand_color": "Cor da marca", "brightness": "brilho", "button_label": "Rótulo do Botão", @@ -1366,7 +1364,6 @@ "does_not_start_with": "Não começa com", "edit_recall": "Editar Lembrete", "edit_translations": "Editar traduções de {lang}", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Habilitar criptografia do Id de Uso Único (suId) na URL da pesquisa.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.", "enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.", "enable_spam_protection": "Proteção contra spam", @@ -1442,7 +1439,6 @@ "hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica", "hostname": "nome do host", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}", - "how_it_works": "Como funciona", "if_you_need_more_please": "Se você precisar de mais, por favor", "if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.", "ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas", @@ -1480,7 +1476,6 @@ "limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo", "limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para", "link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.", - "link_used_message": "Link Usado", "load_segment": "segmento de carga", "logic_error_warning": "Mudar vai causar erros de lógica", "logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta", @@ -1572,8 +1567,6 @@ "show_survey_to_users": "Mostrar pesquisa para % dos usuários", "show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados", "simple": "Simples", - "single_use_survey_links": "Links de pesquisa de uso único", - "single_use_survey_links_description": "Permitir apenas 1 resposta por link da pesquisa.", "six_points": "6 pontos", "skip_button_label": "Botão de Pular", "smiley": "Sorridente", @@ -1590,8 +1583,6 @@ "subheading": "Subtítulo", "subtract": "Subtrair -", "suggest_colors": "Sugerir cores", - "survey_already_answered_heading": "A pesquisa já foi respondida.", - "survey_already_answered_subheading": "Você só pode usar esse link uma vez.", "survey_completed_heading": "Pesquisa Concluída", "survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada", "survey_display_settings": "Configurações de Exibição da Pesquisa", @@ -1622,7 +1613,6 @@ "upload": "Enviar", "upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens", "upper_label": "Etiqueta Superior", - "url_encryption": "Criptografia de URL", "url_filters": "Filtros de URL", "url_not_supported": "URL não suportada", "use_with_caution": "Use com cuidado", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index de892dfa8a..273afe5529 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1280,8 +1280,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).", "back_button_label": "Rótulo do botão \"Voltar\"", "background_styling": "Estilo de Fundo", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia o inquérito se já existir uma submissão com o Id de Uso Único (suId).", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia o inquérito se o URL do inquérito não tiver um Id de Uso Único (suId).", "brand_color": "Cor da marca", "brightness": "Brilho", "button_label": "Rótulo do botão", @@ -1366,7 +1364,6 @@ "does_not_start_with": "Não começa com", "edit_recall": "Editar Lembrete", "edit_translations": "Editar traduções {lang}", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Ativar encriptação do Id de Uso Único (suId) no URL do inquérito.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.", "enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.", "enable_spam_protection": "Proteção contra spam", @@ -1442,7 +1439,6 @@ "hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico", "hostname": "Nome do host", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}", - "how_it_works": "Como funciona", "if_you_need_more_please": "Se precisar de mais, por favor", "if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.", "ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos", @@ -1480,7 +1476,6 @@ "limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro", "limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a", "link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.", - "link_used_message": "Link Utilizado", "load_segment": "Carregar segmento", "logic_error_warning": "A alteração causará erros de lógica", "logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta", @@ -1572,8 +1567,6 @@ "show_survey_to_users": "Mostrar inquérito a % dos utilizadores", "show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo", "simple": "Simples", - "single_use_survey_links": "Links de inquérito de uso único", - "single_use_survey_links_description": "Permitir apenas 1 resposta por link de inquérito.", "six_points": "6 pontos", "skip_button_label": "Rótulo do botão Ignorar", "smiley": "Sorridente", @@ -1590,8 +1583,6 @@ "subheading": "Subtítulo", "subtract": "Subtrair -", "suggest_colors": "Sugerir cores", - "survey_already_answered_heading": "O inquérito já foi respondido.", - "survey_already_answered_subheading": "Só pode usar este link uma vez.", "survey_completed_heading": "Inquérito Concluído", "survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado", "survey_display_settings": "Configurações de Exibição do Inquérito", @@ -1622,7 +1613,6 @@ "upload": "Carregar", "upload_at_least_2_images": "Carregue pelo menos 2 imagens", "upper_label": "Etiqueta Superior", - "url_encryption": "Encriptação de URL", "url_filters": "Filtros de URL", "url_not_supported": "URL não suportado", "use_with_caution": "Usar com cautela", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 606b1a9d6e..8a92a682dc 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1280,8 +1280,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "在指定日期(UTC時間)自動發佈問卷。", "back_button_label": "「返回」按鈕標籤", "background_styling": "背景樣式設定", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "如果已存在具有單次使用 ID (suId) 的提交,則封鎖問卷。", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "如果問卷網址沒有單次使用 ID (suId),則封鎖問卷。", "brand_color": "品牌顏色", "brightness": "亮度", "button_label": "按鈕標籤", @@ -1366,7 +1364,6 @@ "does_not_start_with": "不以...開頭", "edit_recall": "編輯回憶", "edit_translations": "編輯 '{'language'}' 翻譯", - "enable_encryption_of_single_use_id_suid_in_survey_url": "啟用問卷網址中單次使用 ID (suId) 的加密。", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。", "enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。", "enable_spam_protection": "垃圾郵件保護", @@ -1442,7 +1439,6 @@ "hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌", "hostname": "主機名稱", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫", - "how_it_works": "運作方式", "if_you_need_more_please": "如果您需要更多,請", "if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。", "ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間", @@ -1480,7 +1476,6 @@ "limit_the_maximum_file_size": "限制最大檔案大小", "limit_upload_file_size_to": "限制上傳檔案大小為", "link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。", - "link_used_message": "已使用連結", "load_segment": "載入區隔", "logic_error_warning": "變更將導致邏輯錯誤", "logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件", @@ -1572,8 +1567,6 @@ "show_survey_to_users": "將問卷顯示給 % 的使用者", "show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者", "simple": "簡單", - "single_use_survey_links": "單次使用問卷連結", - "single_use_survey_links_description": "每個問卷連結只允許 1 個回應。", "six_points": "6 分", "skip_button_label": "「跳過」按鈕標籤", "smiley": "表情符號", @@ -1590,8 +1583,6 @@ "subheading": "副標題", "subtract": "減 -", "suggest_colors": "建議顏色", - "survey_already_answered_heading": "問卷已回答。", - "survey_already_answered_subheading": "您只能使用此連結一次。", "survey_completed_heading": "問卷已完成", "survey_completed_subheading": "此免費且開源的問卷已關閉", "survey_display_settings": "問卷顯示設定", @@ -1622,7 +1613,6 @@ "upload": "上傳", "upload_at_least_2_images": "上傳至少 2 張圖片", "upper_label": "上標籤", - "url_encryption": "網址加密", "url_filters": "網址篩選器", "url_not_supported": "不支援網址", "use_with_caution": "謹慎使用", diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx index 10b831a0e0..f85604f73e 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx @@ -4,6 +4,7 @@ import { toast } from "react-hot-toast"; import { afterEach, describe, expect, test, vi } from "vitest"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; +import { getSurveyUrl } from "../../utils"; vi.mock("react-hot-toast", () => ({ toast: { @@ -11,6 +12,24 @@ vi.mock("react-hot-toast", () => ({ }, })); +// Mock the useSingleUseId hook +vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({ + useSingleUseId: vi.fn(() => ({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"), + })), +})); + +// Mock the survey utils +vi.mock("../../utils", () => ({ + getSurveyUrl: vi.fn((survey, publicDomain, language) => { + if (language && language !== "en") { + return `${publicDomain}/s/${survey.id}?lang=${language}`; + } + return `${publicDomain}/s/${survey.id}`; + }), +})); + const survey: TSurvey = { id: "survey-id", name: "Test Survey", @@ -161,7 +180,7 @@ describe("ShareSurveyLink", () => { expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard"); }); - test("opens the preview link in a new tab when preview button is clicked (no query params)", () => { + test("opens the preview link in a new tab when preview button is clicked (no query params)", async () => { render( { const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab"); fireEvent.click(previewButton); + // Wait for the async function to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank"); }); - test("opens the preview link in a new tab when preview button is clicked (with query params)", () => { + test("opens the preview link in a new tab when preview button is clicked (with query params)", async () => { const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`; render( { const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab"); fireEvent.click(previewButton); + // Wait for the async function to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank"); }); @@ -215,7 +240,9 @@ describe("ShareSurveyLink", () => { }); test("updates the survey URL when the language is changed", () => { - const { rerender } = render( + const mockGetSurveyUrl = vi.mocked(getSurveyUrl); + + render( { const germanOption = screen.getByText("German"); fireEvent.click(germanOption); - rerender( - - ); - expect(setSurveyUrl).toHaveBeenCalled(); - expect(surveyUrl).toContain("lang=de"); + expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de"); + expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`); }); }); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx index e2029b352f..2cb4bf8e67 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { Copy, SquareArrowOutUpRight } from "lucide-react"; @@ -32,6 +33,22 @@ export const ShareSurveyLink = ({ setSurveyUrl(url); }; + const { refreshSingleUseId } = useSingleUseId(survey); + + const getPreviewUrl = async () => { + const previewUrl = new URL(surveyUrl); + + if (survey.singleUse?.enabled) { + const newId = await refreshSingleUseId(); + if (newId) { + previewUrl.searchParams.set("suId", newId); + } + } + + previewUrl.searchParams.set("preview", "true"); + return previewUrl.toString(); + }; + return (
@@ -53,14 +70,9 @@ export const ShareSurveyLink = ({ title={t("environments.surveys.preview_survey_in_a_new_tab")} aria-label={t("environments.surveys.preview_survey_in_a_new_tab")} disabled={!surveyUrl} - onClick={() => { - let previewUrl = surveyUrl; - if (previewUrl.includes("?")) { - previewUrl += "&preview=true"; - } else { - previewUrl += "?preview=true"; - } - window.open(previewUrl, "_blank"); + onClick={async () => { + const url = await getPreviewUrl(); + window.open(url, "_blank"); }}> {t("common.preview")} diff --git a/apps/web/modules/survey/hooks/useSingleUseId.tsx b/apps/web/modules/survey/hooks/useSingleUseId.tsx index cd91311316..63ee15cfd6 100644 --- a/apps/web/modules/survey/hooks/useSingleUseId.tsx +++ b/apps/web/modules/survey/hooks/useSingleUseId.tsx @@ -14,7 +14,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList) => { if (survey.singleUse?.enabled) { const response = await generateSingleUseIdsAction({ surveyId: survey.id, - isEncrypted: !!survey.singleUse?.isEncrypted, + isEncrypted: Boolean(survey.singleUse?.isEncrypted), count: 1, });