mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
feat: sharing modal anonymous links (#6224)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
@@ -313,3 +313,42 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
count: csvData.length,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateSingleUseLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
environmentId: ZId,
|
||||
isSingleUse: z.boolean(),
|
||||
isSingleUseEncryption: z.boolean(),
|
||||
});
|
||||
|
||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||
.schema(ZUpdateSingleUseLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const updatedSurvey = await updateSurvey({
|
||||
...survey,
|
||||
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
|
||||
});
|
||||
|
||||
return updatedSurvey;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<Button variant="secondary" className="h-6 w-6">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,6 @@ export const SurveyAnalysisCTA = ({
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
<Button
|
||||
className="h-10"
|
||||
onClick={() => {
|
||||
setModalState((prev) => ({ ...prev, share: true }));
|
||||
}}>
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("correctly configures for 'link' survey type in embed view", () => {
|
||||
test("correctly configures for 'anon-links' survey type in embed view", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
|
||||
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
@@ -221,12 +221,12 @@ describe("ShareEmbedSurvey", () => {
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "dynamic-popup")).toBeDefined();
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "website-embed")).toBeDefined();
|
||||
expect(embedViewProps.tabs[0].id).toBe("link");
|
||||
expect(embedViewProps.tabs[0].id).toBe("anon-links");
|
||||
expect(embedViewProps.tabs[1].id).toBe("qr-code");
|
||||
expect(embedViewProps.tabs[2].id).toBe("personal-links");
|
||||
expect(embedViewProps.tabs[3].id).toBe("email");
|
||||
expect(embedViewProps.tabs[4].id).toBe("website-embed");
|
||||
expect(embedViewProps.activeId).toBe("link");
|
||||
expect(embedViewProps.activeId).toBe("anon-links");
|
||||
});
|
||||
|
||||
test("correctly configures for 'web' survey type in embed view", () => {
|
||||
@@ -277,8 +277,8 @@ describe("ShareEmbedSurvey", () => {
|
||||
let embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
||||
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
|
||||
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "anon-links");
|
||||
expect(linkTab?.label).toBe("environments.surveys.share.anonymous_links.nav_title");
|
||||
cleanup();
|
||||
vi.mocked(mockShareViewComponent).mockClear();
|
||||
|
||||
@@ -290,8 +290,8 @@ describe("ShareEmbedSurvey", () => {
|
||||
embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
||||
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
|
||||
linkTab = embedViewProps.tabs.find((tab) => tab.id === "anon-links");
|
||||
expect(linkTab?.label).toBe("environments.surveys.share.anonymous_links.nav_title");
|
||||
});
|
||||
|
||||
test("includes QR code tab for link surveys", () => {
|
||||
@@ -336,7 +336,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
test("QR code tab appears after link tab in the tabs array", () => {
|
||||
const linkTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "link");
|
||||
const linkTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "anon-links");
|
||||
const qrCodeTabIndex = embedViewProps.tabs.findIndex((tab) => tab.id === "qr-code");
|
||||
expect(qrCodeTabIndex).toBe(linkTabIndex + 1);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -23,16 +23,6 @@ import { SuccessView } from "./shareEmbedModal/success-view";
|
||||
|
||||
type ModalView = "start" | "share";
|
||||
|
||||
enum ShareViewType {
|
||||
LINK = "link",
|
||||
QR_CODE = "qr-code",
|
||||
PERSONAL_LINKS = "personal-links",
|
||||
EMAIL = "email",
|
||||
WEBSITE_EMBED = "website-embed",
|
||||
DYNAMIC_POPUP = "dynamic-popup",
|
||||
APP = "app",
|
||||
}
|
||||
|
||||
interface ShareSurveyModalProps {
|
||||
survey: TSurvey;
|
||||
publicDomain: string;
|
||||
@@ -57,14 +47,13 @@ export const ShareSurveyModal = ({
|
||||
isFormbricksCloud,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
|
||||
const { email } = user;
|
||||
const { t } = useTranslate();
|
||||
const linkTabs: { id: ShareViewType; label: string; icon: React.ElementType }[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: ShareViewType.LINK,
|
||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||
id: ShareViewType.ANON_LINKS,
|
||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
@@ -74,53 +63,42 @@ export const ShareSurveyModal = ({
|
||||
},
|
||||
{
|
||||
id: ShareViewType.PERSONAL_LINKS,
|
||||
label: t("environments.surveys.summary.personal_links"),
|
||||
label: t("environments.surveys.share.personal_links.nav_title"),
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
id: ShareViewType.EMAIL,
|
||||
label: t("environments.surveys.summary.embed_in_an_email"),
|
||||
label: t("environments.surveys.share.send_email.nav_title"),
|
||||
icon: MailIcon,
|
||||
},
|
||||
{
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: t("environments.surveys.summary.embed_on_website"),
|
||||
label: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
icon: Code2Icon,
|
||||
},
|
||||
{
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: t("environments.surveys.summary.dynamic_popup"),
|
||||
label: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
icon: SquareStack,
|
||||
},
|
||||
],
|
||||
[t, isSingleUseLinkSurvey]
|
||||
[t]
|
||||
);
|
||||
|
||||
const appTabs = [
|
||||
{
|
||||
id: ShareViewType.APP,
|
||||
label: t("environments.surveys.summary.embed_in_app"),
|
||||
label: t("environments.surveys.share.embed_on_website.embed_in_app"),
|
||||
icon: SmartphoneIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP);
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
const [activeId, setActiveId] = useState(
|
||||
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, publicDomain, "default");
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch survey URL:", error);
|
||||
// Fallback to a default URL if fetching fails
|
||||
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, publicDomain]);
|
||||
const [surveyUrl, setSurveyUrl] = useState(() => getSurveyUrl(survey, publicDomain, "default"));
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -129,7 +107,7 @@ export const ShareSurveyModal = ({
|
||||
}, [open, modalView]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setActiveId(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP);
|
||||
setActiveId(survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP);
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
@@ -150,7 +128,11 @@ export const ShareSurveyModal = ({
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle />
|
||||
</VisuallyHidden>
|
||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide" aria-describedby={undefined}>
|
||||
<DialogContent
|
||||
className="w-full overflow-y-auto bg-white p-0 lg:h-[700px]"
|
||||
width="wide"
|
||||
aria-describedby={undefined}
|
||||
unconstrained>
|
||||
{showView === "start" ? (
|
||||
<SuccessView
|
||||
survey={survey}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { H4 } from "@/modules/ui/components/typography";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface DynamicPopupTabProps {
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
}
|
||||
|
||||
interface DocumentationButtonProps {
|
||||
href: string;
|
||||
title: string;
|
||||
readDocsText: string;
|
||||
}
|
||||
|
||||
const DocumentationButton = ({ href, title, readDocsText }: DocumentationButtonProps) => {
|
||||
return (
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ExternalLinkIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-left text-sm">{title}</span>
|
||||
</div>
|
||||
<span>{readDocsText}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>{t("environments.surveys.summary.dynamic_popup.alert_title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.summary.dynamic_popup.alert_description")}
|
||||
</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
|
||||
{t("environments.surveys.summary.dynamic_popup.alert_button")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
<H4>{t("environments.surveys.summary.dynamic_popup.title")}</H4>
|
||||
<DocumentationButton
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting"
|
||||
title={t("environments.surveys.summary.dynamic_popup.attribute_based_targeting")}
|
||||
readDocsText={t("environments.surveys.summary.dynamic_popup.read_documentation")}
|
||||
/>
|
||||
<DocumentationButton
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
|
||||
title={t("environments.surveys.summary.dynamic_popup.code_no_code_triggers")}
|
||||
readDocsText={t("environments.surveys.summary.dynamic_popup.read_documentation")}
|
||||
/>
|
||||
<DocumentationButton
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact"
|
||||
title={t("environments.surveys.summary.dynamic_popup.recontact_options")}
|
||||
readDocsText={t("environments.surveys.summary.dynamic_popup.read_documentation")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Code2Icon, CopyIcon, MailIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
|
||||
|
||||
interface EmailTabProps {
|
||||
surveyId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const { t } = useTranslate();
|
||||
const emailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return emailHtmlPreview
|
||||
.replaceAll("?preview=true&", "?")
|
||||
.replaceAll("?preview=true&;", "?")
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async () => {
|
||||
const emailHtml = await getEmailHtmlAction({ surveyId });
|
||||
setEmailHtmlPreview(emailHtml?.data || "");
|
||||
};
|
||||
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
if (val?.data) {
|
||||
toast.success(t("environments.surveys.summary.email_sent"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(val);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
toast.error(t("common.not_authenticated"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
{showEmbed ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Embed survey in your website"
|
||||
aria-label="Embed survey in your website"
|
||||
onClick={() => {
|
||||
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
|
||||
navigator.clipboard.writeText(emailHtml);
|
||||
}}
|
||||
className="shrink-0">
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="send preview email"
|
||||
aria-label="send preview email"
|
||||
onClick={() => sendPreviewEmail()}
|
||||
className="shrink-0">
|
||||
{t("environments.surveys.summary.send_preview")}
|
||||
<MailIcon />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
title={t("environments.surveys.summary.view_embed_code_for_email")}
|
||||
aria-label={t("environments.surveys.summary.view_embed_code_for_email")}
|
||||
onClick={() => {
|
||||
setShowEmbed(!showEmbed);
|
||||
}}
|
||||
className="shrink-0">
|
||||
{showEmbed
|
||||
? t("environments.surveys.summary.hide_embed_code")
|
||||
: t("environments.surveys.summary.view_embed_code")}
|
||||
<Code2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
{showEmbed ? (
|
||||
<div className="prose prose-slate -mt-4 max-w-full">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-48 overflow-y-scroll"
|
||||
language="html"
|
||||
showCopyToClipboard={false}>
|
||||
{emailHtml}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-12 grow overflow-y-auto rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">To : {email || "user@mail.com"}</div>
|
||||
<div className="border-b border-slate-200 pb-2 text-sm">
|
||||
Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
|
||||
// Mock ShareSurveyLink
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
|
||||
<div data-testid="share-survey-link">
|
||||
Mocked ShareSurveyLink
|
||||
<span data-testid="survey-id">{survey.id}</span>
|
||||
<span data-testid="survey-url">{surveyUrl}</span>
|
||||
<span data-testid="public-domain">{publicDomain}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: mockTranslate,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: any) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
thankYouCard: { enabled: false },
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
|
||||
const mockPublicDomain = "https://app.formbricks.com";
|
||||
const mockSetSurveyUrl = vi.fn();
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
const docsLinksExpected = [
|
||||
{
|
||||
titleKey: "environments.surveys.summary.data_prefilling",
|
||||
descriptionKey: "environments.surveys.summary.data_prefilling_description",
|
||||
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
|
||||
},
|
||||
{
|
||||
titleKey: "environments.surveys.summary.source_tracking",
|
||||
descriptionKey: "environments.surveys.summary.source_tracking_description",
|
||||
link: "https://formbricks.com/docs/link-surveys/source-tracking",
|
||||
},
|
||||
{
|
||||
titleKey: "environments.surveys.summary.create_single_use_links",
|
||||
descriptionKey: "environments.surveys.summary.create_single_use_links_description",
|
||||
link: "https://formbricks.com/docs/link-surveys/single-use-links",
|
||||
},
|
||||
];
|
||||
|
||||
describe("LinkTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the main title", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.share_the_link_to_get_responses")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ShareSurveyLink with correct props", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
|
||||
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
|
||||
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
|
||||
});
|
||||
|
||||
test("renders the promotional text for link surveys", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all documentation links correctly", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
docsLinksExpected.forEach((doc) => {
|
||||
const linkElement = screen.getByText(doc.titleKey).closest("a");
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
expect(linkElement).toHaveAttribute("href", doc.link);
|
||||
expect(linkElement).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByText(doc.descriptionKey)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling_description");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking_description");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.create_single_use_links");
|
||||
expect(mockTranslate).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.create_single_use_links_description"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface LinkTabProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const docsLinks = [
|
||||
{
|
||||
title: t("environments.surveys.summary.data_prefilling"),
|
||||
description: t("environments.surveys.summary.data_prefilling_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.summary.source_tracking"),
|
||||
description: t("environments.surveys.summary.source_tracking_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/source-tracking",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.summary.create_single_use_links"),
|
||||
description: t("environments.surveys.summary.create_single_use_links_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/single-use-links",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col gap-6">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.share_the_link_to_get_responses")}
|
||||
</p>
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<p className="pt-2 font-semibold text-slate-700">
|
||||
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{docsLinks.map((tip) => (
|
||||
<Link
|
||||
key={tip.title}
|
||||
target="_blank"
|
||||
href={tip.link}
|
||||
className="relative w-full rounded-md border border-slate-100 bg-white px-6 py-4 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-800">
|
||||
<p className="mb-1 font-semibold">{tip.title}</p>
|
||||
<p className="text-slate-500 hover:text-slate-700">{tip.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AnonymousLinksTab } from "./anonymous-links-tab";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("../../actions", () => ({
|
||||
updateSingleUseLinksAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
generateSingleUseIdsAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: ({ surveyUrl, publicDomain }: any) => (
|
||||
<div data-testid="share-survey-link">
|
||||
<p>Survey URL: {surveyUrl}</p>
|
||||
<p>Public Domain: {publicDomain}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
|
||||
AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => (
|
||||
<div data-testid={`toggle-${htmlId}`} data-checked={isChecked}>
|
||||
<button data-testid={`toggle-button-${htmlId}`} onClick={() => onToggle(!isChecked)}>
|
||||
{title}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children, variant, size }: any) => (
|
||||
<div data-testid={`alert-${variant}`} data-size={size}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
|
||||
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, disabled, variant }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} data-variant={variant}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, type, max, min, className }: any) => (
|
||||
<input
|
||||
type={type}
|
||||
max={max}
|
||||
min={min}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="number-input"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
|
||||
() => ({
|
||||
TabContainer: ({ children, title }: any) => (
|
||||
<div data-testid="tab-container">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal",
|
||||
() => ({
|
||||
DisableLinkModal: ({ open, type, onDisable }: any) => (
|
||||
<div data-testid="disable-link-modal" data-open={open} data-type={type}>
|
||||
<button onClick={() => onDisable()}>Confirm</button>
|
||||
<button>Close</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links",
|
||||
() => ({
|
||||
DocumentationLinks: ({ links }: any) => (
|
||||
<div data-testid="documentation-links">
|
||||
{links.map((link: any, index: number) => (
|
||||
<a key={index} href={link.href}>
|
||||
{link.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock translations
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Next.js router
|
||||
const mockRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock URL and Blob for download functionality
|
||||
global.URL.createObjectURL = vi.fn(() => "mock-url");
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
global.Blob = vi.fn(() => ({}) as any);
|
||||
|
||||
describe("AnonymousLinksTab", () => {
|
||||
const mockSurvey = {
|
||||
id: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
type: "link" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
createdBy: null,
|
||||
status: "draft" as const,
|
||||
questions: [],
|
||||
thankYouCard: { enabled: false },
|
||||
welcomeCard: { enabled: false },
|
||||
hiddenFields: { enabled: false },
|
||||
singleUse: {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
singleUse: {
|
||||
enabled: true,
|
||||
isEncrypted: false,
|
||||
},
|
||||
} as TSurvey;
|
||||
|
||||
const surveyWithEncryption = {
|
||||
...mockSurvey,
|
||||
singleUse: {
|
||||
enabled: true,
|
||||
isEncrypted: true,
|
||||
},
|
||||
} as TSurvey;
|
||||
|
||||
const defaultProps = {
|
||||
survey: mockSurvey,
|
||||
surveyUrl: "https://example.com/survey",
|
||||
publicDomain: "https://example.com",
|
||||
setSurveyUrl: vi.fn(),
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
|
||||
|
||||
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: mockSurvey });
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: ["link1", "link2"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders with multi-use link enabled by default", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "false");
|
||||
});
|
||||
|
||||
test("renders with single-use link enabled when survey has singleUse enabled", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "false");
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
});
|
||||
|
||||
test("handles multi-use toggle when single-use is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
// When multi-use is enabled and we click it, it should show a modal to turn it off
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
// Should show confirmation modal
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
|
||||
|
||||
// Confirm the modal action
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
isSingleUse: true,
|
||||
isSingleUseEncryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows confirmation modal when toggling from single-use to multi-use", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "single-use");
|
||||
});
|
||||
|
||||
test("shows confirmation modal when toggling from multi-use to single-use", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
const singleUseToggle = screen.getByTestId("toggle-button-single-use-link-switch");
|
||||
await user.click(singleUseToggle);
|
||||
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
|
||||
});
|
||||
|
||||
test("handles single-use encryption toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const encryptionToggle = screen.getByTestId("toggle-button-single-use-encryption-switch");
|
||||
await user.click(encryptionToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
isSingleUse: true,
|
||||
isSingleUseEncryption: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("shows encryption info alert when encryption is disabled", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const alerts = screen.getAllByTestId("alert-info");
|
||||
const encryptionAlert = alerts.find(
|
||||
(alert) =>
|
||||
alert.querySelector('[data-testid="alert-title"]')?.textContent ===
|
||||
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
|
||||
);
|
||||
|
||||
expect(encryptionAlert).toBeInTheDocument();
|
||||
expect(encryptionAlert?.querySelector('[data-testid="alert-title"]')).toHaveTextContent(
|
||||
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
|
||||
);
|
||||
});
|
||||
|
||||
test("shows link generation section when encryption is enabled", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
expect(screen.getByTestId("number-input")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.generate_and_download_links")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles number of links input change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
const input = screen.getByTestId("number-input");
|
||||
await user.clear(input);
|
||||
await user.type(input, "5");
|
||||
|
||||
expect(input).toHaveValue(5);
|
||||
});
|
||||
|
||||
test("handles link generation error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: undefined });
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
const generateButton = screen.getByText(
|
||||
"environments.surveys.share.anonymous_links.generate_and_download_links"
|
||||
);
|
||||
await user.click(generateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"environments.surveys.share.anonymous_links.generate_links_error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles action error with generic message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: undefined });
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
// Click multi-use toggle to show modal
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
// Confirm the modal action
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
|
||||
});
|
||||
});
|
||||
|
||||
test("confirms modal action when disable link modal is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
isSingleUse: false,
|
||||
isSingleUseEncryption: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("renders documentation links", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.single_use_links")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
|
||||
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CirclePlayIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface AnonymousLinksTabProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const AnonymousLinksTab = ({
|
||||
survey,
|
||||
surveyUrl,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
|
||||
const [isMultiUseLink, setIsMultiUseLink] = useState(!survey.singleUse?.enabled);
|
||||
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
|
||||
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
|
||||
|
||||
const [disableLinkModal, setDisableLinkModal] = useState<{
|
||||
open: boolean;
|
||||
type: "multi-use" | "single-use";
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
|
||||
setIsMultiUseLink(!enabled);
|
||||
setIsSingleUseLink(enabled ?? false);
|
||||
setSingleUseEncryption(isEncrypted ?? false);
|
||||
};
|
||||
|
||||
const updateSingleUseSettings = async (
|
||||
isSingleUse: boolean,
|
||||
isSingleUseEncryption: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const updatedSurveyResponse = await updateSingleUseLinksAction({
|
||||
surveyId: survey.id,
|
||||
environmentId: survey.environmentId,
|
||||
isSingleUse,
|
||||
isSingleUseEncryption,
|
||||
});
|
||||
|
||||
if (updatedSurveyResponse?.data) {
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
resetState();
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
resetState();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultiUseToggle = async (newValue: boolean) => {
|
||||
if (newValue) {
|
||||
// Turning multi-use on - show confirmation modal if single-use is currently enabled
|
||||
if (isSingleUseLink) {
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "single-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(true);
|
||||
setIsSingleUseLink(false);
|
||||
setSingleUseEncryption(false);
|
||||
await updateSingleUseSettings(false, false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Single-use is already off, just enable multi-use
|
||||
setIsMultiUseLink(true);
|
||||
setIsSingleUseLink(false);
|
||||
setSingleUseEncryption(false);
|
||||
await updateSingleUseSettings(false, false);
|
||||
}
|
||||
} else {
|
||||
// Turning multi-use off - need confirmation and turn single-use on
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "multi-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(false);
|
||||
setIsSingleUseLink(true);
|
||||
setSingleUseEncryption(true);
|
||||
await updateSingleUseSettings(true, true);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseToggle = async (newValue: boolean) => {
|
||||
if (newValue) {
|
||||
// Turning single-use on - turn multi-use off
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "multi-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(false);
|
||||
setIsSingleUseLink(true);
|
||||
setSingleUseEncryption(true);
|
||||
await updateSingleUseSettings(true, true);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Turning single-use off - show confirmation modal and then turn multi-use on
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "single-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(true);
|
||||
setIsSingleUseLink(false);
|
||||
setSingleUseEncryption(false);
|
||||
await updateSingleUseSettings(false, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseEncryptionToggle = async (newValue: boolean) => {
|
||||
setSingleUseEncryption(newValue);
|
||||
await updateSingleUseSettings(true, newValue);
|
||||
};
|
||||
|
||||
const handleNumberOfLinksChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (inputValue === "") {
|
||||
setNumberOfLinks("");
|
||||
return;
|
||||
}
|
||||
|
||||
const value = Number(inputValue);
|
||||
|
||||
if (!isNaN(value)) {
|
||||
setNumberOfLinks(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateLinks = async (count: number) => {
|
||||
try {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: singleUseEncryption,
|
||||
count,
|
||||
});
|
||||
|
||||
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
|
||||
|
||||
// Create content with just the links
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
|
||||
// Create and download the file
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `single-use-links-${survey.id}.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.anonymous_links.title")}
|
||||
description={t("environments.surveys.share.anonymous_links.description")}>
|
||||
<div className="flex h-full w-full grow flex-col gap-6">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="multi-use-link-switch"
|
||||
isChecked={isMultiUseLink}
|
||||
onToggle={handleMultiUseToggle}
|
||||
title={t("environments.surveys.share.anonymous_links.multi_use_link")}
|
||||
description={t("environments.surveys.share.anonymous_links.multi_use_link_description")}
|
||||
customContainerClass="p-0"
|
||||
childBorder>
|
||||
<div className="flex w-full flex-col gap-4 overflow-hidden bg-white p-4">
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
htmlId="single-use-link-switch"
|
||||
isChecked={isSingleUseLink}
|
||||
onToggle={handleSingleUseToggle}
|
||||
title={t("environments.surveys.share.anonymous_links.single_use_link")}
|
||||
description={t("environments.surveys.share.anonymous_links.single_use_link_description")}
|
||||
customContainerClass="p-0"
|
||||
childBorder>
|
||||
<div className="flex w-full flex-col gap-4 bg-white p-4">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="single-use-encryption-switch"
|
||||
isChecked={singleUseEncryption}
|
||||
onToggle={handleSingleUseEncryptionToggle}
|
||||
title={t("environments.surveys.share.anonymous_links.url_encryption_label")}
|
||||
description={t("environments.surveys.share.anonymous_links.url_encryption_description")}
|
||||
customContainerClass="p-0"
|
||||
/>
|
||||
|
||||
{!singleUseEncryption ? (
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{singleUseEncryption && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<h3 className="text-sm font-medium text-slate-900">
|
||||
{t("environments.surveys.share.anonymous_links.number_of_links_label")}
|
||||
</h3>
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
max={5000}
|
||||
min={1}
|
||||
className="bg-white focus:border focus:border-slate-900"
|
||||
value={numberOfLinks}
|
||||
onChange={handleNumberOfLinksChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
|
||||
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-50">
|
||||
{t("environments.surveys.share.anonymous_links.generate_and_download_links")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
|
||||
<DocumentationLinks
|
||||
links={[
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.single_use_links"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.data_prefilling"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.source_tracking"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{disableLinkModal && (
|
||||
<DisableLinkModal
|
||||
open={disableLinkModal.open}
|
||||
onOpenChange={() => setDisableLinkModal(null)}
|
||||
type={disableLinkModal.type}
|
||||
onDisable={() => {
|
||||
disableLinkModal.pendingAction();
|
||||
setDisableLinkModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { AppTab } from "./AppTab";
|
||||
import { AppTab } from "./app-tab";
|
||||
|
||||
vi.mock("@/modules/ui/components/options-switch", () => ({
|
||||
OptionsSwitch: (props: {
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { DisableLinkModal } from "./disable-link-modal";
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const onOpenChange = vi.fn();
|
||||
const onDisable = vi.fn();
|
||||
|
||||
describe("DisableLinkModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should render the modal for multi-use link", () => {
|
||||
render(
|
||||
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should render the modal for single-use link", () => {
|
||||
render(
|
||||
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.are_you_sure")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should call onDisable and onOpenChange when the disable button is clicked for multi-use", async () => {
|
||||
render(
|
||||
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
|
||||
);
|
||||
|
||||
const disableButton = screen.getByText(
|
||||
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button"
|
||||
);
|
||||
await userEvent.click(disableButton);
|
||||
|
||||
expect(onDisable).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should call onDisable and onOpenChange when the disable button is clicked for single-use", async () => {
|
||||
render(
|
||||
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
|
||||
);
|
||||
|
||||
const disableButton = screen.getByText(
|
||||
"environments.surveys.share.anonymous_links.disable_single_use_link_modal_button"
|
||||
);
|
||||
await userEvent.click(disableButton);
|
||||
|
||||
expect(onDisable).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should call onOpenChange when the cancel button is clicked", async () => {
|
||||
render(
|
||||
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByText("common.cancel");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should not render the modal when open is false", () => {
|
||||
const { container } = render(
|
||||
<DisableLinkModal open={false} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
interface DisableLinkModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: "multi-use" | "single-use";
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: DisableLinkModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
|
||||
<DialogHeader className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{type === "multi-use" ? (
|
||||
<>
|
||||
<p>
|
||||
{t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")}
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
<p>
|
||||
{t(
|
||||
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}</p>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
onDisable();
|
||||
onOpenChange(false);
|
||||
}}>
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button")
|
||||
: t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_button")}
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { DocumentationLinks } from "./documentation-links";
|
||||
|
||||
describe("DocumentationLinks", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockLinks = [
|
||||
{
|
||||
title: "Getting Started Guide",
|
||||
href: "https://docs.formbricks.com/getting-started",
|
||||
},
|
||||
{
|
||||
title: "API Documentation",
|
||||
href: "https://docs.formbricks.com/api",
|
||||
},
|
||||
{
|
||||
title: "Integration Guide",
|
||||
href: "https://docs.formbricks.com/integrations",
|
||||
},
|
||||
];
|
||||
|
||||
test("renders all documentation links", () => {
|
||||
render(<DocumentationLinks links={mockLinks} />);
|
||||
|
||||
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
|
||||
expect(screen.getByText("API Documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Integration Guide")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct number of alert components", () => {
|
||||
render(<DocumentationLinks links={mockLinks} />);
|
||||
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("renders learn more links with correct href attributes", () => {
|
||||
render(<DocumentationLinks links={mockLinks} />);
|
||||
|
||||
const learnMoreLinks = screen.getAllByText("common.learn_more");
|
||||
expect(learnMoreLinks).toHaveLength(3);
|
||||
|
||||
expect(learnMoreLinks[0]).toHaveAttribute("href", "https://docs.formbricks.com/getting-started");
|
||||
expect(learnMoreLinks[1]).toHaveAttribute("href", "https://docs.formbricks.com/api");
|
||||
expect(learnMoreLinks[2]).toHaveAttribute("href", "https://docs.formbricks.com/integrations");
|
||||
});
|
||||
|
||||
test("renders learn more links with target blank", () => {
|
||||
render(<DocumentationLinks links={mockLinks} />);
|
||||
|
||||
const learnMoreLinks = screen.getAllByText("common.learn_more");
|
||||
learnMoreLinks.forEach((link) => {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders learn more links with correct CSS classes", () => {
|
||||
render(<DocumentationLinks links={mockLinks} />);
|
||||
|
||||
const learnMoreLinks = screen.getAllByText("common.learn_more");
|
||||
learnMoreLinks.forEach((link) => {
|
||||
expect(link).toHaveClass("text-slate-900", "hover:underline");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders empty list when no links provided", () => {
|
||||
render(<DocumentationLinks links={[]} />);
|
||||
|
||||
const alerts = screen.queryAllByRole("alert");
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("renders single link correctly", () => {
|
||||
const singleLink = [mockLinks[0]];
|
||||
render(<DocumentationLinks links={singleLink} />);
|
||||
|
||||
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.learn_more")).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.formbricks.com/getting-started"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with correct container structure", () => {
|
||||
const { container } = render(<DocumentationLinks links={mockLinks} />);
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement;
|
||||
expect(mainContainer).toHaveClass("flex", "w-full", "flex-col", "space-y-2");
|
||||
|
||||
const linkContainers = mainContainer.children;
|
||||
expect(linkContainers).toHaveLength(3);
|
||||
|
||||
Array.from(linkContainers).forEach((linkContainer) => {
|
||||
expect(linkContainer).toHaveClass("flex", "w-full", "flex-col", "gap-3");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface DocumentationLinksProps {
|
||||
links: {
|
||||
title: string;
|
||||
href: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col space-y-2">
|
||||
{links.map((link) => (
|
||||
<div key={link.title} className="flex w-full flex-col gap-3">
|
||||
<Alert variant="outbound" size="small">
|
||||
<AlertTitle>{link.title}</AlertTitle>
|
||||
<AlertButton asChild>
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-900 hover:underline">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { DynamicPopupTab } from "./DynamicPopupTab";
|
||||
import { DynamicPopupTab } from "./dynamic-popup-tab";
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
@@ -30,7 +30,13 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/typography", () => ({
|
||||
H3: (props: { children: React.ReactNode }) => <div data-testid="h3">{props.children}</div>,
|
||||
H4: (props: { children: React.ReactNode }) => <div data-testid="h4">{props.children}</div>,
|
||||
Small: (props: { children: React.ReactNode; color?: string; margin?: string }) => (
|
||||
<div data-testid="small" data-color={props.color} data-margin={props.margin}>
|
||||
{props.children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
@@ -69,18 +75,20 @@ describe("DynamicPopupTab", () => {
|
||||
test("renders alert with correct props", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const alert = screen.getByTestId("alert");
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(alert).toHaveAttribute("data-variant", "info");
|
||||
expect(alert).toHaveAttribute("data-size", "default");
|
||||
const alerts = screen.getAllByTestId("alert");
|
||||
const infoAlert = alerts.find((alert) => alert.getAttribute("data-variant") === "info");
|
||||
expect(infoAlert).toBeInTheDocument();
|
||||
expect(infoAlert).toHaveAttribute("data-variant", "info");
|
||||
expect(infoAlert).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
test("renders alert title with translation key", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const alertTitle = screen.getByTestId("alert-title");
|
||||
expect(alertTitle).toBeInTheDocument();
|
||||
expect(alertTitle).toHaveTextContent("environments.surveys.summary.dynamic_popup.alert_title");
|
||||
const alertTitles = screen.getAllByTestId("alert-title");
|
||||
const infoAlertTitle = alertTitles[0]; // The first one is the info alert
|
||||
expect(infoAlertTitle).toBeInTheDocument();
|
||||
expect(infoAlertTitle).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_title");
|
||||
});
|
||||
|
||||
test("renders alert description with translation key", () => {
|
||||
@@ -88,38 +96,37 @@ describe("DynamicPopupTab", () => {
|
||||
|
||||
const alertDescription = screen.getByTestId("alert-description");
|
||||
expect(alertDescription).toBeInTheDocument();
|
||||
expect(alertDescription).toHaveTextContent(
|
||||
"environments.surveys.summary.dynamic_popup.alert_description"
|
||||
);
|
||||
expect(alertDescription).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_description");
|
||||
});
|
||||
|
||||
test("renders alert button with link to survey edit page", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const alertButton = screen.getByTestId("alert-button");
|
||||
expect(alertButton).toBeInTheDocument();
|
||||
expect(alertButton).toHaveAttribute("data-as-child", "true");
|
||||
const alertButtons = screen.getAllByTestId("alert-button");
|
||||
const infoAlertButton = alertButtons[0]; // The first one is the info alert
|
||||
expect(infoAlertButton).toBeInTheDocument();
|
||||
expect(infoAlertButton).toHaveAttribute("data-as-child", "true");
|
||||
|
||||
const link = screen.getAllByTestId("next-link")[0];
|
||||
expect(link).toHaveAttribute("href", "/environments/env-123/surveys/survey-123/edit");
|
||||
expect(link).toHaveTextContent("environments.surveys.summary.dynamic_popup.alert_button");
|
||||
expect(link).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_button");
|
||||
});
|
||||
|
||||
test("renders title with correct text", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const h4 = screen.getByTestId("h4");
|
||||
expect(h4).toBeInTheDocument();
|
||||
expect(h4).toHaveTextContent("environments.surveys.summary.dynamic_popup.title");
|
||||
const h3 = screen.getByTestId("h3");
|
||||
expect(h3).toBeInTheDocument();
|
||||
expect(h3).toHaveTextContent("environments.surveys.share.dynamic_popup.title");
|
||||
});
|
||||
|
||||
test("renders attribute-based targeting documentation button", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const links = screen.getAllByTestId("next-link");
|
||||
const links = screen.getAllByRole("link");
|
||||
const attributeLink = links.find((link) => link.getAttribute("href")?.includes("advanced-targeting"));
|
||||
|
||||
expect(attributeLink).toBeInTheDocument();
|
||||
expect(attributeLink).toBeDefined();
|
||||
expect(attributeLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting"
|
||||
@@ -130,10 +137,10 @@ describe("DynamicPopupTab", () => {
|
||||
test("renders code and no code triggers documentation button", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const links = screen.getAllByTestId("next-link");
|
||||
const links = screen.getAllByRole("link");
|
||||
const actionsLink = links.find((link) => link.getAttribute("href")?.includes("actions"));
|
||||
|
||||
expect(actionsLink).toBeInTheDocument();
|
||||
expect(actionsLink).toBeDefined();
|
||||
expect(actionsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
|
||||
@@ -144,10 +151,10 @@ describe("DynamicPopupTab", () => {
|
||||
test("renders recontact options documentation button", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const links = screen.getAllByTestId("next-link");
|
||||
const links = screen.getAllByRole("link");
|
||||
const recontactLink = links.find((link) => link.getAttribute("href")?.includes("recontact"));
|
||||
|
||||
expect(recontactLink).toBeInTheDocument();
|
||||
expect(recontactLink).toBeDefined();
|
||||
expect(recontactLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact"
|
||||
@@ -158,18 +165,27 @@ describe("DynamicPopupTab", () => {
|
||||
test("all documentation buttons have external link icons", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const externalLinkIcons = screen.getAllByTestId("external-link-icon");
|
||||
expect(externalLinkIcons).toHaveLength(3);
|
||||
const links = screen.getAllByRole("link");
|
||||
const documentationLinks = links.filter(
|
||||
(link) =>
|
||||
link.getAttribute("href")?.includes("formbricks.com/docs") && link.getAttribute("target") === "_blank"
|
||||
);
|
||||
|
||||
externalLinkIcons.forEach((icon) => {
|
||||
expect(icon).toHaveClass("h-4 w-4 flex-shrink-0");
|
||||
// There are 3 unique documentation URLs
|
||||
expect(documentationLinks).toHaveLength(3);
|
||||
|
||||
documentationLinks.forEach((link) => {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
test("documentation button links open in new tab", () => {
|
||||
render(<DynamicPopupTab {...defaultProps} />);
|
||||
|
||||
const documentationLinks = screen.getAllByTestId("next-link").slice(1, 4); // Skip the alert button link
|
||||
const links = screen.getAllByRole("link");
|
||||
const documentationLinks = links.filter((link) =>
|
||||
link.getAttribute("href")?.includes("formbricks.com/docs")
|
||||
);
|
||||
|
||||
documentationLinks.forEach((link) => {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface DynamicPopupTabProps {
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
}
|
||||
|
||||
export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.dynamic_popup.title")}
|
||||
description={t("environments.surveys.share.dynamic_popup.description")}>
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>{t("environments.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.dynamic_popup.alert_description")}
|
||||
</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
|
||||
{t("environments.surveys.share.dynamic_popup.alert_button")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
|
||||
<DocumentationLinks
|
||||
links={[
|
||||
{
|
||||
title: t("environments.surveys.share.dynamic_popup.attribute_based_targeting"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.dynamic_popup.code_no_code_triggers"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.dynamic_popup.recontact_options"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TabContainer>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
|
||||
import { EmailTab } from "./EmailTab";
|
||||
import { EmailTab } from "./email-tab";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("../../actions", () => ({
|
||||
@@ -20,15 +20,23 @@ vi.mock("@/lib/utils/helper", () => ({
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, title, ...props }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} title={title} {...props}>
|
||||
Button: ({ children, onClick, variant, title, "aria-label": ariaLabel, ...props }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} title={title} aria-label={ariaLabel} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/code-block", () => ({
|
||||
CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => (
|
||||
<div data-testid="code-block" data-language={language}>
|
||||
CodeBlock: ({
|
||||
children,
|
||||
language,
|
||||
showCopyToClipboard,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
language: string;
|
||||
showCopyToClipboard?: boolean;
|
||||
}) => (
|
||||
<div data-testid="code-block" data-language={language} data-show-copy={showCopyToClipboard}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -41,7 +49,9 @@ vi.mock("@/modules/ui/components/loading-spinner", () => ({
|
||||
vi.mock("lucide-react", () => ({
|
||||
Code2Icon: () => <div data-testid="code2-icon" />,
|
||||
CopyIcon: () => <div data-testid="copy-icon" />,
|
||||
EyeIcon: () => <div data-testid="eye-icon" />,
|
||||
MailIcon: () => <div data-testid="mail-icon" />,
|
||||
SendIcon: () => <div data-testid="send-icon" />,
|
||||
}));
|
||||
|
||||
// Mock navigator.clipboard
|
||||
@@ -74,22 +84,42 @@ describe("EmailTab", () => {
|
||||
expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalledWith({ surveyId });
|
||||
|
||||
// Buttons
|
||||
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
|
||||
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mail-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
// Note: code2-icon is only visible in the embed code tab, not in initial render
|
||||
|
||||
// Email preview section
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
|
||||
const emailToElements = screen.getAllByText((content, element) => {
|
||||
return (
|
||||
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
|
||||
);
|
||||
});
|
||||
expect(emailToElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.getByText("Subject : environments.surveys.summary.formbricks_email_survey_preview")
|
||||
).toBeInTheDocument();
|
||||
screen.getAllByText((content, element) => {
|
||||
return (
|
||||
element?.textContent?.includes("environments.surveys.share.send_email.email_subject_label") || false
|
||||
);
|
||||
}).length
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByText((content, element) => {
|
||||
return (
|
||||
element?.textContent?.includes(
|
||||
"environments.surveys.share.send_email.formbricks_email_survey_preview"
|
||||
) || false
|
||||
);
|
||||
}).length
|
||||
).toBeGreaterThan(0);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hello World ?preview=true&foo=bar")).toBeInTheDocument(); // Raw HTML content
|
||||
expect(screen.getByText("Hello World ?foo=bar")).toBeInTheDocument(); // HTML content rendered as text (preview=true removed)
|
||||
});
|
||||
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -99,32 +129,47 @@ describe("EmailTab", () => {
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const viewEmbedButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.summary.view_embed_code_for_email",
|
||||
name: "environments.surveys.share.send_email.embed_code_tab",
|
||||
});
|
||||
await userEvent.click(viewEmbedButton);
|
||||
|
||||
// Embed code view
|
||||
expect(screen.getByRole("button", { name: "Embed survey in your website" })).toBeInTheDocument(); // Updated name
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" }) // Updated name for hide button
|
||||
screen.getByRole("button", { name: "environments.surveys.share.send_email.copy_embed_code" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
|
||||
const codeBlock = screen.getByTestId("code-block");
|
||||
expect(codeBlock).toBeInTheDocument();
|
||||
expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML
|
||||
expect(screen.queryByText(`To : ${userEmail}`)).not.toBeInTheDocument();
|
||||
|
||||
// Toggle back
|
||||
const hideEmbedButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.summary.view_embed_code_for_email", // Updated name for hide button
|
||||
});
|
||||
await userEvent.click(hideEmbedButton);
|
||||
|
||||
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
|
||||
// The email_to_label should not be visible in embed code view
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
|
||||
screen.queryByText((content, element) => {
|
||||
return (
|
||||
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
|
||||
);
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Toggle back to preview
|
||||
const previewButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.share.send_email.email_preview_tab",
|
||||
});
|
||||
await userEvent.click(previewButton);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const emailToElements = screen.getAllByText((content, element) => {
|
||||
return (
|
||||
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
|
||||
);
|
||||
});
|
||||
expect(emailToElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -133,16 +178,19 @@ describe("EmailTab", () => {
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const viewEmbedButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.summary.view_embed_code_for_email",
|
||||
name: "environments.surveys.share.send_email.embed_code_tab",
|
||||
});
|
||||
await userEvent.click(viewEmbedButton);
|
||||
|
||||
// Ensure this line queries by the correct aria-label
|
||||
const copyCodeButton = screen.getByRole("button", { name: "Embed survey in your website" });
|
||||
const copyCodeButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.share.send_email.copy_embed_code",
|
||||
});
|
||||
await userEvent.click(copyCodeButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml);
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.embed_code_copied_to_clipboard");
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"environments.surveys.share.send_email.embed_code_copied_to_clipboard"
|
||||
);
|
||||
});
|
||||
|
||||
test("sends preview email successfully", async () => {
|
||||
@@ -150,11 +198,13 @@ describe("EmailTab", () => {
|
||||
render(<EmailTab surveyId={surveyId} email={userEmail} />);
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
|
||||
const sendPreviewButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.share.send_email.send_preview_email",
|
||||
});
|
||||
await userEvent.click(sendPreviewButton);
|
||||
|
||||
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.email_sent");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.share.send_email.email_sent");
|
||||
});
|
||||
|
||||
test("handles send preview email failure (server error)", async () => {
|
||||
@@ -163,7 +213,9 @@ describe("EmailTab", () => {
|
||||
render(<EmailTab surveyId={surveyId} email={userEmail} />);
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
|
||||
const sendPreviewButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.share.send_email.send_preview_email",
|
||||
});
|
||||
await userEvent.click(sendPreviewButton);
|
||||
|
||||
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
|
||||
@@ -176,7 +228,9 @@ describe("EmailTab", () => {
|
||||
render(<EmailTab surveyId={surveyId} email={userEmail} />);
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
|
||||
const sendPreviewButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.share.send_email.send_preview_email",
|
||||
});
|
||||
await userEvent.click(sendPreviewButton);
|
||||
|
||||
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
|
||||
@@ -190,7 +244,9 @@ describe("EmailTab", () => {
|
||||
render(<EmailTab surveyId={surveyId} email={userEmail} />);
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
|
||||
const sendPreviewButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.share.send_email.send_preview_email",
|
||||
});
|
||||
await userEvent.click(sendPreviewButton);
|
||||
|
||||
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
|
||||
@@ -208,14 +264,19 @@ describe("EmailTab", () => {
|
||||
test("renders default email if email prop is not provided", async () => {
|
||||
render(<EmailTab surveyId={surveyId} email="" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("To : user@mail.com")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((content, element) => {
|
||||
return (
|
||||
element?.textContent === "environments.surveys.share.send_email.email_to_label : user@mail.com"
|
||||
);
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("emailHtml memo removes various ?preview=true patterns", async () => {
|
||||
const htmlWithVariants =
|
||||
"<p>Test1 ?preview=true</p><p>Test2 ?preview=true&next</p><p>Test3 ?preview=true&;next</p>";
|
||||
// Ensure this line matches the "Received" output from your test error
|
||||
const expectedCleanHtml = "<p>Test1 </p><p>Test2 ?next</p><p>Test3 ?next</p>";
|
||||
vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: htmlWithVariants });
|
||||
|
||||
@@ -223,7 +284,7 @@ describe("EmailTab", () => {
|
||||
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
|
||||
|
||||
const viewEmbedButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.summary.view_embed_code_for_email",
|
||||
name: "environments.surveys.share.send_email.embed_code_tab",
|
||||
});
|
||||
await userEvent.click(viewEmbedButton);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { TabBar } from "@/modules/ui/components/tab-bar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { CopyIcon, SendIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
|
||||
|
||||
interface EmailTabProps {
|
||||
surveyId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const { t } = useTranslate();
|
||||
|
||||
const emailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return emailHtmlPreview
|
||||
.replaceAll("?preview=true&", "?")
|
||||
.replaceAll("?preview=true&;", "?")
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "preview",
|
||||
label: t("environments.surveys.share.send_email.email_preview_tab"),
|
||||
},
|
||||
{
|
||||
id: "embed",
|
||||
label: t("environments.surveys.share.send_email.embed_code_tab"),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async () => {
|
||||
const emailHtml = await getEmailHtmlAction({ surveyId });
|
||||
setEmailHtmlPreview(emailHtml?.data || "");
|
||||
};
|
||||
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
if (val?.data) {
|
||||
toast.success(t("environments.surveys.share.send_email.email_sent"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(val);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
toast.error(t("common.not_authenticated"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (activeTab === "preview") {
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
|
||||
{t("environments.surveys.share.send_email.email_to_label")} : {email || "user@mail.com"}
|
||||
</div>
|
||||
<div className="border-b border-slate-200 pb-2 text-sm">
|
||||
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
|
||||
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
title={t("environments.surveys.share.send_email.send_preview_email")}
|
||||
aria-label={t("environments.surveys.share.send_email.send_preview_email")}
|
||||
onClick={() => sendPreviewEmail()}
|
||||
className="shrink-0">
|
||||
{t("environments.surveys.share.send_email.send_preview")}
|
||||
<SendIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "embed") {
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-96 overflow-y-scroll"
|
||||
language="html"
|
||||
showCopyToClipboard
|
||||
noMargin>
|
||||
{emailHtml}
|
||||
</CodeBlock>
|
||||
<Button
|
||||
title={t("environments.surveys.share.send_email.copy_embed_code")}
|
||||
aria-label={t("environments.surveys.share.send_email.copy_embed_code")}
|
||||
onClick={() => {
|
||||
try {
|
||||
navigator.clipboard.writeText(emailHtml);
|
||||
toast.success(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard"));
|
||||
} catch {
|
||||
toast.error(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard_failed"));
|
||||
}
|
||||
}}
|
||||
className="shrink-0">
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.send_email.title")}
|
||||
description={t("environments.surveys.share.send_email.description")}>
|
||||
<div className="flex h-full w-full flex-col space-y-4">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeId={activeTab}
|
||||
setActiveId={setActiveTab}
|
||||
tabStyle="button"
|
||||
className="h-10 min-h-10 rounded-md border border-slate-200 bg-slate-100"
|
||||
/>
|
||||
<div className="flex-1">{renderTabContent()}</div>
|
||||
</div>
|
||||
</TabContainer>
|
||||
);
|
||||
};
|
||||
@@ -195,12 +195,8 @@ describe("PersonalLinksTab", () => {
|
||||
test("renders the component with correct title and description", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.generate_personal_links_title")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.generate_personal_links_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.personal_links.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.personal_links.description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders recipients section with segment selection", () => {
|
||||
@@ -208,15 +204,21 @@ describe("PersonalLinksTab", () => {
|
||||
|
||||
expect(screen.getByText("common.recipients")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("select")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.personal_links.create_and_manage_segments")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders expiry date section with date picker", () => {
|
||||
render(<PersonalLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.personal_links.expiry_date_optional")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.personal_links.expiry_date_description")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders generate button with correct initial state", () => {
|
||||
@@ -225,7 +227,9 @@ describe("PersonalLinksTab", () => {
|
||||
const button = screen.getByTestId("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.personal_links.generate_and_download_links")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -234,7 +238,7 @@ describe("PersonalLinksTab", () => {
|
||||
|
||||
expect(screen.getByTestId("alert")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
|
||||
screen.getByText("environments.surveys.share.personal_links.work_with_segments")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link")).toHaveAttribute(
|
||||
"href",
|
||||
@@ -259,7 +263,9 @@ describe("PersonalLinksTab", () => {
|
||||
|
||||
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
|
||||
expect(screen.getByTestId("button")).toBeDisabled();
|
||||
});
|
||||
@@ -341,10 +347,13 @@ describe("PersonalLinksTab", () => {
|
||||
});
|
||||
|
||||
// Verify loading toast
|
||||
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
expect(mockToast.loading).toHaveBeenCalledWith(
|
||||
"environments.surveys.share.personal_links.generating_links_toast",
|
||||
{
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("generates links with expiry date when date is selected", async () => {
|
||||
@@ -439,10 +448,13 @@ describe("PersonalLinksTab", () => {
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// Verify loading toast is called
|
||||
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
expect(mockToast.loading).toHaveBeenCalledWith(
|
||||
"environments.surveys.share.personal_links.generating_links_toast",
|
||||
{
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("button is disabled when no segment is selected", () => {
|
||||
@@ -472,7 +484,9 @@ describe("PersonalLinksTab", () => {
|
||||
|
||||
render(<PersonalLinksTab {...propsWithEmptySegments} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DatePicker } from "@/modules/ui/components/date-picker";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,11 +22,12 @@ import {
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { generatePersonalLinksAction } from "../../actions";
|
||||
import { TabContainer } from "./tab-container";
|
||||
|
||||
interface PersonalLinksTabProps {
|
||||
environmentId: string;
|
||||
@@ -28,6 +37,11 @@ interface PersonalLinksTabProps {
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
interface PersonalLinksFormData {
|
||||
selectedSegment: string;
|
||||
expiryDate: Date | null;
|
||||
}
|
||||
|
||||
// Custom DatePicker component with date restrictions
|
||||
const RestrictedDatePicker = ({
|
||||
date,
|
||||
@@ -63,8 +77,18 @@ export const PersonalLinksTab = ({
|
||||
isFormbricksCloud,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [selectedSegment, setSelectedSegment] = useState<string>("");
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
|
||||
const form = useForm<PersonalLinksFormData>({
|
||||
defaultValues: {
|
||||
selectedSegment: "",
|
||||
expiryDate: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { watch } = form;
|
||||
const selectedSegment = watch("selectedSegment");
|
||||
const expiryDate = watch("expiryDate");
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const publicSegments = segments.filter((segment) => !segment.isPrivate);
|
||||
|
||||
@@ -84,7 +108,7 @@ export const PersonalLinksTab = ({
|
||||
setIsGenerating(true);
|
||||
|
||||
// Show initial toast
|
||||
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
|
||||
toast.loading(t("environments.surveys.share.personal_links.generating_links_toast"), {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
@@ -100,7 +124,7 @@ export const PersonalLinksTab = ({
|
||||
|
||||
if (result?.data) {
|
||||
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
|
||||
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
|
||||
toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
|
||||
duration: 5000,
|
||||
id: "generating-links",
|
||||
});
|
||||
@@ -117,14 +141,14 @@ export const PersonalLinksTab = ({
|
||||
// Button state logic
|
||||
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
|
||||
const buttonText = isGenerating
|
||||
? t("environments.surveys.summary.generating_links")
|
||||
: t("environments.surveys.summary.generate_and_download_links");
|
||||
? t("environments.surveys.share.personal_links.generating_links")
|
||||
: t("environments.surveys.share.personal_links.generate_and_download_links");
|
||||
|
||||
if (!isContactsEnabled) {
|
||||
return (
|
||||
<UpgradePrompt
|
||||
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
|
||||
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
|
||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
@@ -144,88 +168,87 @@ export const PersonalLinksTab = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.generate_personal_links_title")}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.generate_personal_links_description")}
|
||||
</p>
|
||||
</div>
|
||||
<FormProvider {...form}>
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.personal_links.title")}
|
||||
description={t("environments.surveys.share.personal_links.description")}>
|
||||
<div className="flex h-full grow flex-col gap-6">
|
||||
{/* Recipients Section */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="selectedSegment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.recipients")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={publicSegments.length === 0}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
publicSegments.length === 0
|
||||
? t("environments.surveys.share.personal_links.no_segments_available")
|
||||
: t("environments.surveys.share.personal_links.select_segment")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{publicSegments.map((segment) => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.personal_links.create_and_manage_segments")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Recipients Section */}
|
||||
<div>
|
||||
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{t("common.recipients")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedSegment}
|
||||
onValueChange={setSelectedSegment}
|
||||
disabled={publicSegments.length === 0}>
|
||||
<SelectTrigger id="segment-select" className="w-full bg-white">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
publicSegments.length === 0
|
||||
? t("environments.surveys.summary.no_segments_available")
|
||||
: t("environments.surveys.summary.select_segment")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{publicSegments.map((segment) => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.summary.create_and_manage_segments")}
|
||||
</p>
|
||||
{/* Expiry Date Section */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiryDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("environments.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
|
||||
<FormControl>
|
||||
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.personal_links.expiry_date_description")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={handleGenerateLinks}
|
||||
disabled={isButtonDisabled}
|
||||
loading={isGenerating}
|
||||
className="w-fit">
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{/* Expiry Date Section */}
|
||||
<div>
|
||||
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{t("environments.surveys.summary.expiry_date_optional")}
|
||||
</label>
|
||||
<div id="expiry-date-picker">
|
||||
<RestrictedDatePicker
|
||||
date={expiryDate}
|
||||
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.summary.expiry_date_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={handleGenerateLinks}
|
||||
disabled={isButtonDisabled}
|
||||
loading={isGenerating}
|
||||
className="w-fit">
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{/* Info Box */}
|
||||
<Alert variant="info" size="small">
|
||||
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
|
||||
<AlertButton>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
{/* Info Box */}
|
||||
<DocumentationLinks
|
||||
links={[
|
||||
{
|
||||
title: t("environments.surveys.share.personal_links.work_with_segments"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TabContainer>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
@@ -1,52 +1,45 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ShareViewType } from "../../types/share";
|
||||
import { ShareView } from "./share-view";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("./AppTab", () => ({
|
||||
vi.mock("./app-tab", () => ({
|
||||
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
|
||||
}));
|
||||
vi.mock("./EmailTab", () => ({
|
||||
|
||||
vi.mock("./email-tab", () => ({
|
||||
EmailTab: (props: { surveyId: string; email: string }) => (
|
||||
<div data-testid="email-tab">
|
||||
EmailTab Content for {props.surveyId} with {props.email}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./LinkTab", () => ({
|
||||
LinkTab: (props: { survey: any; surveyUrl: string }) => (
|
||||
<div data-testid="link-tab">
|
||||
LinkTab Content for {props.survey.id} at {props.surveyUrl}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./QRCodeTab", () => ({
|
||||
|
||||
vi.mock("./qr-code-tab", () => ({
|
||||
QRCodeTab: (props: { surveyUrl: string }) => (
|
||||
<div data-testid="qr-code-tab">QRCodeTab Content for {props.surveyUrl}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./WebsiteTab", () => ({
|
||||
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
|
||||
<div data-testid="website-tab">
|
||||
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./WebsiteEmbedTab", () => ({
|
||||
vi.mock("./website-embed-tab", () => ({
|
||||
WebsiteEmbedTab: (props: { surveyUrl: string }) => (
|
||||
<div data-testid="website-embed-tab">WebsiteEmbedTab Content for {props.surveyUrl}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./DynamicPopupTab", () => ({
|
||||
|
||||
vi.mock("./dynamic-popup-tab", () => ({
|
||||
DynamicPopupTab: (props: { environmentId: string; surveyId: string }) => (
|
||||
<div data-testid="dynamic-popup-tab">
|
||||
DynamicPopupTab Content for {props.surveyId} in {props.environmentId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./TabContainer", () => ({
|
||||
|
||||
vi.mock("./tab-container", () => ({
|
||||
TabContainer: (props: { children: React.ReactNode; title: string; description: string }) => (
|
||||
<div data-testid="tab-container">
|
||||
<div data-testid="tab-title">{props.title}</div>
|
||||
@@ -64,6 +57,20 @@ vi.mock("./personal-links-tab", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./anonymous-links-tab", () => ({
|
||||
AnonymousLinksTab: (props: {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
}) => (
|
||||
<div data-testid="anonymous-links-tab">
|
||||
AnonymousLinksTab Content for {props.survey.id} at {props.surveyUrl}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
|
||||
UpgradePrompt: (props: { title: string; description: string }) => (
|
||||
<div data-testid="upgrade-prompt">
|
||||
@@ -81,25 +88,27 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon">CopyIcon</div>,
|
||||
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
|
||||
ArrowUpRightIcon: () => <div data-testid="arrow-up-right-icon">ArrowUpRightIcon</div>,
|
||||
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
|
||||
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
|
||||
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
|
||||
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
|
||||
CheckCircle2Icon: () => <div data-testid="check-circle-2-icon">CheckCircle2Icon</div>,
|
||||
AlertCircle: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="alert-circle">
|
||||
AlertCircle
|
||||
AlertCircleIcon: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="alert-circle-icon">
|
||||
AlertCircleIcon
|
||||
</div>
|
||||
),
|
||||
AlertTriangle: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="alert-triangle">
|
||||
AlertTriangle
|
||||
AlertTriangleIcon: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="alert-triangle-icon">
|
||||
AlertTriangleIcon
|
||||
</div>
|
||||
),
|
||||
Info: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="info">
|
||||
Info
|
||||
InfoIcon: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="info-icon">
|
||||
InfoIcon
|
||||
</div>
|
||||
),
|
||||
Download: ({ className }: { className?: string }) => (
|
||||
@@ -169,13 +178,21 @@ vi.mock("@/lib/cn", () => ({
|
||||
cn: (...args: any[]) => args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
const mockTabs = [
|
||||
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
|
||||
{ id: "website-embed", label: "Website Embed", icon: () => <div data-testid="website-embed-tab-icon" /> },
|
||||
{ id: "dynamic-popup", label: "Dynamic Popup", icon: () => <div data-testid="dynamic-popup-tab-icon" /> },
|
||||
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
|
||||
{ id: "qr-code", label: "QR Code", icon: () => <div data-testid="qr-code-tab-icon" /> },
|
||||
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-icon" /> },
|
||||
const mockTabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }> = [
|
||||
{ id: ShareViewType.EMAIL, label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
|
||||
{
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: "Website Embed",
|
||||
icon: () => <div data-testid="website-embed-tab-icon" />,
|
||||
},
|
||||
{
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: "Dynamic Popup",
|
||||
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
|
||||
},
|
||||
{ id: ShareViewType.ANON_LINKS, label: "Anonymous Links", icon: () => <div data-testid="link-tab-icon" /> },
|
||||
{ id: ShareViewType.QR_CODE, label: "QR Code", icon: () => <div data-testid="qr-code-tab-icon" /> },
|
||||
{ id: ShareViewType.APP, label: "App", icon: () => <div data-testid="app-tab-icon" /> },
|
||||
];
|
||||
|
||||
const mockSurveyLink = {
|
||||
@@ -223,7 +240,7 @@ const mockSurveyWeb = {
|
||||
|
||||
const defaultProps = {
|
||||
tabs: mockTabs,
|
||||
activeId: "email",
|
||||
activeId: ShareViewType.EMAIL,
|
||||
setActiveId: vi.fn(),
|
||||
environmentId: "env1",
|
||||
survey: mockSurveyLink,
|
||||
@@ -253,23 +270,23 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("renders desktop tabs for link survey type", () => {
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} />);
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// For link survey types, desktop sidebar should be rendered
|
||||
const sidebarLabel = screen.getByText("Share via");
|
||||
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
|
||||
expect(sidebarLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a tab is clicked (desktop)", async () => {
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
|
||||
await userEvent.click(websiteEmbedTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith("website-embed");
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
|
||||
});
|
||||
|
||||
test("renders EmailTab when activeId is 'email'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="email" />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
|
||||
@@ -277,15 +294,13 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("renders WebsiteEmbedTab when activeId is 'website-embed'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="website-embed" />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
|
||||
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText(`WebsiteEmbedTab Content for ${defaultProps.surveyUrl}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DynamicPopupTab when activeId is 'dynamic-popup'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="dynamic-popup" />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
|
||||
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -294,26 +309,26 @@ describe("ShareView", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LinkTab when activeId is 'link'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="link" />);
|
||||
expect(screen.getByTestId("link-tab")).toBeInTheDocument();
|
||||
test("renders AnonymousLinksTab when activeId is 'anon-links'", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
|
||||
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
|
||||
screen.getByText(`AnonymousLinksTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders QRCodeTab when activeId is 'qr-code'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="qr-code" />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
|
||||
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AppTab when activeId is 'app'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="app" />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.APP} />);
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders PersonalLinksTab when activeId is 'personal-links'", () => {
|
||||
render(<ShareView {...defaultProps} activeId="personal-links" />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
|
||||
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -323,7 +338,7 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("calls setActiveId when a responsive tab is clicked", async () => {
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Get responsive buttons - these are Button components containing icons
|
||||
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
|
||||
@@ -337,12 +352,12 @@ describe("ShareView", () => {
|
||||
|
||||
if (responsiveButton) {
|
||||
await userEvent.click(responsiveButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith("website-embed");
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
|
||||
}
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (desktop)", () => {
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
const emailTabButton = screen.getByLabelText("Email");
|
||||
expect(emailTabButton).toHaveClass("bg-slate-100");
|
||||
@@ -355,7 +370,7 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (responsive)", () => {
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
render(<ShareView {...defaultProps} survey={mockSurveyLink} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Get responsive buttons - these are Button components with ghost variant
|
||||
const responsiveButtons = screen.getAllByTestId("email-tab-icon");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/DynamicPopupTab";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/TabContainer";
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -23,16 +23,16 @@ import { useEffect, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AppTab } from "./AppTab";
|
||||
import { EmailTab } from "./EmailTab";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
import { WebsiteEmbedTab } from "./WebsiteEmbedTab";
|
||||
import { AnonymousLinksTab } from "./anonymous-links-tab";
|
||||
import { AppTab } from "./app-tab";
|
||||
import { EmailTab } from "./email-tab";
|
||||
import { PersonalLinksTab } from "./personal-links-tab";
|
||||
import { WebsiteEmbedTab } from "./website-embed-tab";
|
||||
|
||||
interface ShareViewProps {
|
||||
tabs: Array<{ id: string; label: string; icon: React.ElementType }>;
|
||||
activeId: string;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<string>>;
|
||||
tabs: Array<{ id: ShareViewType; label: string; icon: React.ElementType }>;
|
||||
activeId: ShareViewType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
email: string;
|
||||
@@ -60,8 +60,8 @@ export const ShareView = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: ShareViewProps) => {
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(true);
|
||||
const { t } = useTranslate();
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
@@ -77,27 +77,15 @@ export const ShareView = ({
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeId) {
|
||||
case "email":
|
||||
case ShareViewType.EMAIL:
|
||||
return <EmailTab surveyId={survey.id} email={email} />;
|
||||
case "website-embed":
|
||||
case ShareViewType.WEBSITE_EMBED:
|
||||
return <WebsiteEmbedTab surveyUrl={surveyUrl} />;
|
||||
case ShareViewType.DYNAMIC_POPUP:
|
||||
return <DynamicPopupTab environmentId={environmentId} surveyId={survey.id} />;
|
||||
case ShareViewType.ANON_LINKS:
|
||||
return (
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.embed_on_website.title")}
|
||||
description={t("environments.surveys.share.embed_on_website.description")}>
|
||||
<WebsiteEmbedTab surveyUrl={surveyUrl} />
|
||||
</TabContainer>
|
||||
);
|
||||
case "dynamic-popup":
|
||||
return (
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.dynamic_popup.title")}
|
||||
description={t("environments.surveys.share.dynamic_popup.description")}>
|
||||
<DynamicPopupTab environmentId={environmentId} surveyId={survey.id} />
|
||||
</TabContainer>
|
||||
);
|
||||
case "link":
|
||||
return (
|
||||
<LinkTab
|
||||
<AnonymousLinksTab
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
@@ -105,11 +93,11 @@ export const ShareView = ({
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
case "qr-code":
|
||||
return <QRCodeTab surveyUrl={surveyUrl} />;
|
||||
case "app":
|
||||
case ShareViewType.APP:
|
||||
return <AppTab />;
|
||||
case "personal-links":
|
||||
case ShareViewType.QR_CODE:
|
||||
return <QRCodeTab surveyUrl={surveyUrl} />;
|
||||
case ShareViewType.PERSONAL_LINKS:
|
||||
return (
|
||||
<PersonalLinksTab
|
||||
segments={segments}
|
||||
@@ -140,7 +128,9 @@ export const ShareView = ({
|
||||
<SidebarContent className="h-full border-r border-slate-200 bg-white p-4">
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupLabel>
|
||||
<Small className="text-xs text-slate-500">Share via</Small>
|
||||
<Small className="text-xs text-slate-500">
|
||||
{t("environments.surveys.share.share_view_title")}
|
||||
</Small>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TabContainer } from "./TabContainer";
|
||||
import { TabContainer } from "./tab-container";
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/typography", () => ({
|
||||
@@ -9,7 +9,7 @@ interface TabContainerProps {
|
||||
export const TabContainer = ({ title, description, children }: TabContainerProps) => {
|
||||
return (
|
||||
<div className="flex h-full grow flex-col items-start space-y-4">
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<H3>{title}</H3>
|
||||
<Small color="muted" margin="headerDescription">
|
||||
{description}
|
||||
@@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebsiteEmbedTab } from "./WebsiteEmbedTab";
|
||||
import { WebsiteEmbedTab } from "./website-embed-tab";
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
|
||||
@@ -59,7 +59,7 @@ vi.mock("@/modules/ui/components/code-block", () => ({
|
||||
}) => (
|
||||
<div data-testid="code-block">
|
||||
<span data-testid="language">{props.language}</span>
|
||||
<span data-testid="show-copy">{props.showCopyToClipboard.toString()}</span>
|
||||
<span data-testid="show-copy">{props.showCopyToClipboard?.toString() || "false"}</span>
|
||||
{props.noMargin && <span data-testid="no-margin">true</span>}
|
||||
<pre>{props.children}</pre>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ describe("WebsiteEmbedTab", () => {
|
||||
);
|
||||
const toast = await import("react-hot-toast");
|
||||
expect(toast.default.success).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.embed_code_copied_to_clipboard"
|
||||
"environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -185,8 +185,8 @@ describe("WebsiteEmbedTab", () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
const toggle = screen.getByTestId("advanced-option-toggle");
|
||||
expect(toggle).toHaveTextContent("environments.surveys.summary.embed_mode");
|
||||
expect(toggle).toHaveTextContent("environments.surveys.summary.embed_mode_description");
|
||||
expect(toggle).toHaveTextContent("environments.surveys.share.embed_on_website.embed_mode");
|
||||
expect(toggle).toHaveTextContent("environments.surveys.share.embed_on_website.embed_mode_description");
|
||||
expect(screen.getByTestId("custom-container-class")).toHaveTextContent("p-0");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TabContainer } from "./tab-container";
|
||||
|
||||
interface WebsiteEmbedTabProps {
|
||||
surveyUrl: string;
|
||||
@@ -24,22 +25,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
</div>`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="prose prose-slate max-w-full">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-48 overflow-y-scroll"
|
||||
language="html"
|
||||
showCopyToClipboard={false}
|
||||
noMargin>
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<TabContainer
|
||||
title={t("environments.surveys.share.embed_on_website.title")}
|
||||
description={t("environments.surveys.share.embed_on_website.description")}>
|
||||
<CodeBlock language="html" noMargin>
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
htmlId="enableEmbedMode"
|
||||
isChecked={embedModeEnabled}
|
||||
onToggle={setEmbedModeEnabled}
|
||||
title={t("environments.surveys.summary.embed_mode")}
|
||||
description={t("environments.surveys.summary.embed_mode_description")}
|
||||
title={t("environments.surveys.share.embed_on_website.embed_mode")}
|
||||
description={t("environments.surveys.share.embed_on_website.embed_mode_description")}
|
||||
customContainerClass="p-0"
|
||||
/>
|
||||
<Button
|
||||
@@ -47,11 +45,11 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
aria-label={t("common.copy_code")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(iframeCode);
|
||||
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
|
||||
toast.success(t("environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard"));
|
||||
}}>
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</>
|
||||
</TabContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum ShareViewType {
|
||||
ANON_LINKS = "anon-links",
|
||||
PERSONAL_LINKS = "personal-links",
|
||||
EMAIL = "email",
|
||||
WEBPAGE = "webpage",
|
||||
APP = "app",
|
||||
WEBSITE_EMBED = "website-embed",
|
||||
DYNAMIC_POPUP = "dynamic-popup",
|
||||
QR_CODE = "qr-code",
|
||||
}
|
||||
@@ -557,8 +557,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
|
||||
return modifiedSurvey;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating survey");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error updating survey");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
convertDateString,
|
||||
convertDateTimeString,
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"response": "Antwort",
|
||||
"responses": "Antworten",
|
||||
"restart": "Neustart",
|
||||
"retry": "Erneut versuchen",
|
||||
"role": "Rolle",
|
||||
"role_organization": "Rolle (Organisation)",
|
||||
"saas": "SaaS",
|
||||
@@ -1250,6 +1249,8 @@
|
||||
"add_description": "Beschreibung hinzufügen",
|
||||
"add_ending": "Abschluss hinzufügen",
|
||||
"add_ending_below": "Abschluss unten hinzufügen",
|
||||
"add_fallback": "Hinzufügen",
|
||||
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
|
||||
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
|
||||
"add_highlight_border": "Rahmen hinzufügen",
|
||||
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
|
||||
"everyone": "Jeder",
|
||||
"fallback_for": "Ersatz für",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
|
||||
@@ -1699,14 +1701,96 @@
|
||||
"results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.",
|
||||
"search_by_survey_name": "Nach Umfragenamen suchen",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Wenn Sie eine Einmal-ID nicht verschlüsseln, funktioniert jeder Wert für „suid=...“ für eine Antwort.",
|
||||
"custom_single_use_id_title": "Sie können im URL beliebige Werte als Einmal-ID festlegen.",
|
||||
"custom_start_point": "Benutzerdefinierter Startpunkt",
|
||||
"data_prefilling": "Daten-Prefilling",
|
||||
"description": "Antworten, die von diesen Links kommen, werden anonym",
|
||||
"disable_multi_use_link_modal_button": "Mehrfach verwendeten Link deaktivieren",
|
||||
"disable_multi_use_link_modal_description": "Das Deaktivieren des Mehrfachnutzungslinks verhindert, dass jemand mithilfe des Links eine Antwort einreichen kann.",
|
||||
"disable_multi_use_link_modal_description_one": "Das Deaktivieren des Mehrfachnutzungslinks verhindert, dass jemand mithilfe des Links eine Antwort einreichen kann.",
|
||||
"disable_multi_use_link_modal_description_subtext": "Dies wird auch alle aktiven Einbettungen auf Websites, E-Mails, sozialen Medien und QR-Codes stören, die diesen Mehrfachnutzungslink verwenden.",
|
||||
"disable_multi_use_link_modal_description_two": " Dies wird auch alle aktiven Einbettungen auf Websites, E-Mails, sozialen Medien und QR-Codes\n stören, die diesen Mehrfachnutzungslink verwenden.",
|
||||
"disable_multi_use_link_modal_title": "Bist du sicher? Dies könnte aktive Einbettungen stören",
|
||||
"disable_single_use_link_modal_button": "Einmalige Links deaktivieren",
|
||||
"disable_single_use_link_modal_description": "Wenn Sie Einweglinks geteilt haben, können die Teilnehmer nicht mehr auf die Umfrage antworten.",
|
||||
"generate_and_download_links": "Links generieren und herunterladen",
|
||||
"generate_links_error": "Einmalige Verlinkungen konnten nicht generiert werden. Bitte arbeiten Sie direkt mit der API.",
|
||||
"multi_use_link": "Mehrfach verwendet",
|
||||
"multi_use_link_description": "Sammle mehrere Antworten von anonymen Teilnehmern mit einem Link",
|
||||
"multi_use_powers_other_channels_description": "Wenn du es deaktivierst, werden auch diese anderen Vertriebskanäle deaktiviert.",
|
||||
"multi_use_powers_other_channels_title": "Dieser Link ermöglicht Einbettungen auf Websites, Einbettungen in E-Mails, Teilen in sozialen Medien und QR-Codes",
|
||||
"multi_use_toggle_error": "Fehler beim Aktivieren der Mehrfachnutzung, bitte versuche es später erneut",
|
||||
"nav_title": "Anonyme Links",
|
||||
"number_of_links_empty": "Anzahl der Links erforderlich",
|
||||
"number_of_links_label": "Anzahl der Links (1 - 5.000)",
|
||||
"single_use_link": "Einmalige Links",
|
||||
"single_use_link_description": "Erlaube nur eine Antwort pro Umfragelink.",
|
||||
"single_use_links": "Einmalige Links",
|
||||
"source_tracking": "Quellenverfolgung",
|
||||
"title": "Teilen Sie Ihre Umfrage, um Antworten zu sammeln",
|
||||
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
|
||||
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Umfrage bearbeiten",
|
||||
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab ‚Einstellungen‘ im Umfrage-Editor ändern.",
|
||||
"alert_title": "Umfragen-Typ in In-App ändern",
|
||||
"attribute_based_targeting": "Attributbasiertes Targeting",
|
||||
"code_no_code_triggers": "Code- und No-Code-Auslöser",
|
||||
"description": "Formbricks Umfragen können als Pop-up eingebettet werden, basierend auf der Benutzerinteraktion.",
|
||||
"docs_title": "Mehr mit Zwischenumfragen tun",
|
||||
"nav_title": "Dynamisch (Pop-up)",
|
||||
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
|
||||
"title": "Nutzer im Ablauf abfangen, um kontextualisiertes Feedback zu sammeln"
|
||||
},
|
||||
"embed_on_website": {
|
||||
"description": "Formbricks-Umfragen können als statisches Element eingebettet werden.",
|
||||
"embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!",
|
||||
"embed_in_an_email": "In eine E-Mail einbetten",
|
||||
"embed_in_app": "In App einbetten",
|
||||
"embed_mode": "Einbettungsmodus",
|
||||
"embed_mode_description": "Bette deine Umfrage mit einem minimalistischen Design ein, ohne Karten und Hintergrund.",
|
||||
"nav_title": "Auf Website einbetten",
|
||||
"title": "Binden Sie die Umfrage auf Ihrer Webseite ein"
|
||||
}
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
|
||||
"create_single_use_links": "Single-Use Links erstellen",
|
||||
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
|
||||
"description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu.",
|
||||
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
|
||||
"expiry_date_optional": "Ablaufdatum (optional)",
|
||||
"generate_and_download_links": "Links generieren und herunterladen",
|
||||
"generating_links": "Links werden generiert",
|
||||
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
|
||||
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
|
||||
"nav_title": "Persönliche Links",
|
||||
"no_segments_available": "Keine Segmente verfügbar",
|
||||
"select_segment": "Segment auswählen",
|
||||
"title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
|
||||
"upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
|
||||
"upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
|
||||
"work_with_segments": "Persönliche Links funktionieren mit Segmenten."
|
||||
},
|
||||
"send_email": {
|
||||
"copy_embed_code": "Einbettungscode kopieren",
|
||||
"description": "Binden Sie Ihre Umfrage in eine E-Mail ein, um Antworten von Ihrem Publikum zu erhalten.",
|
||||
"email_preview_tab": "E-Mail Vorschau",
|
||||
"email_sent": "E-Mail gesendet!",
|
||||
"email_subject_label": "Betreff",
|
||||
"email_to_label": "An",
|
||||
"embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!",
|
||||
"embed_code_copied_to_clipboard_failed": "Kopieren fehlgeschlagen, bitte versuche es erneut",
|
||||
"embed_code_tab": "Einbettungscode",
|
||||
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
|
||||
"nav_title": "E-Mail-Einbettung",
|
||||
"send_preview": "Vorschau senden",
|
||||
"send_preview_email": "Vorschau-E-Mail senden",
|
||||
"title": "Binden Sie Ihre Umfrage in eine E-Mail ein"
|
||||
},
|
||||
"share_view_title": "Teilen über"
|
||||
},
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filter hinzugefügt für Antworten, bei denen die Antwort auf Frage {questionIdx} {filterComboBoxValue} - {filterValue} ist",
|
||||
@@ -1715,6 +1799,22 @@
|
||||
"all_responses_excel": "Alle Antworten (Excel)",
|
||||
"all_time": "Gesamt",
|
||||
"almost_there": "Fast geschafft! Installiere das Widget, um mit dem Empfang von Antworten zu beginnen.",
|
||||
"anonymous_links": "Anonyme Links",
|
||||
"anonymous_links.custom_start_point": "Benutzerdefinierter Startpunkt",
|
||||
"anonymous_links.data_prefilling": "Daten-Prefilling",
|
||||
"anonymous_links.docs_title": "Mehr mit Link-Umfragen tun",
|
||||
"anonymous_links.multi_use_link": "Mehrfach verwendet",
|
||||
"anonymous_links.multi_use_link_alert_description": "Wenn du es deaktivierst, werden auch diese anderen Vertriebskanäle deaktiviert.",
|
||||
"anonymous_links.multi_use_link_alert_title": "Dieser Link ermöglicht Einbettungen auf Websites, Einbettungen in E-Mails, Teilen in sozialen Medien und QR-Codes",
|
||||
"anonymous_links.multi_use_link_description": "Sammle mehrere Antworten von anonymen Teilnehmern mit einem Link",
|
||||
"anonymous_links.single_use_link": "Einmaliger Link",
|
||||
"anonymous_links.single_use_link_description": "Erlaube nur eine Antwort pro Umfragelink.",
|
||||
"anonymous_links.single_use_link_encryption": "Verschlüsselung der URL für einmalige Nutzung ID",
|
||||
"anonymous_links.single_use_link_encryption_alert_description": "Wenn Sie die Einmal-ID's nicht verschlüsseln, funktioniert jeder Wert für „suid=...“ für eine Antwort.",
|
||||
"anonymous_links.single_use_link_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen",
|
||||
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Links generieren und herunterladen",
|
||||
"anonymous_links.single_use_link_encryption_number_of_links": "Anzahl der Links (1 - 5.000)",
|
||||
"anonymous_links.source_tracking": "Quellenverfolgung",
|
||||
"average": "Durchschnittlich",
|
||||
"completed": "Abgeschlossen",
|
||||
"completed_tooltip": "Anzahl der abgeschlossenen Umfragen.",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"response": "Response",
|
||||
"responses": "Responses",
|
||||
"restart": "Restart",
|
||||
"retry": "Retry",
|
||||
"role": "Role",
|
||||
"role_organization": "Role (Organization)",
|
||||
"saas": "SaaS",
|
||||
@@ -1250,6 +1249,8 @@
|
||||
"add_description": "Add description",
|
||||
"add_ending": "Add ending",
|
||||
"add_ending_below": "Add ending below",
|
||||
"add_fallback": "Add",
|
||||
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
|
||||
"add_hidden_field_id": "Add hidden field ID",
|
||||
"add_highlight_border": "Add highlight border",
|
||||
"add_highlight_border_description": "Add an outer border to your survey card.",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
|
||||
"everyone": "Everyone",
|
||||
"fallback_for": "Fallback for ",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"field_name_eg_score_price": "Field name e.g, score, price",
|
||||
@@ -1699,14 +1701,96 @@
|
||||
"results_unpublished_successfully": "Results unpublished successfully.",
|
||||
"search_by_survey_name": "Search by survey name",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "If you don’t encrypt single-use ID’s, any value for “suid=...” works for one response.",
|
||||
"custom_single_use_id_title": "You can set any value as single-use ID in the URL.",
|
||||
"custom_start_point": "Custom start point",
|
||||
"data_prefilling": "Data prefilling",
|
||||
"description": "Responses coming from these links will be anonymous",
|
||||
"disable_multi_use_link_modal_button": "Disable multi-use link",
|
||||
"disable_multi_use_link_modal_description": "Disabling the multi-use link will prevent anyone to submit a response via the link.",
|
||||
"disable_multi_use_link_modal_description_one": "Disabling the multi-use link will prevent anyone to submit a response via the link.",
|
||||
"disable_multi_use_link_modal_description_subtext": "This will also break any active embeds on Websites, Emails, Social Media and QR codes that use this multi-use link.",
|
||||
"disable_multi_use_link_modal_description_two": " This will also break any active embeds on Websites, Emails, Social Media and QR codes that use\n this multi-use link.",
|
||||
"disable_multi_use_link_modal_title": "Are you sure? This can break active embeddings",
|
||||
"disable_single_use_link_modal_button": "Disable single-use links",
|
||||
"disable_single_use_link_modal_description": "If you shared single-use links, participants will not be able to respond to the survey any longer.",
|
||||
"generate_and_download_links": "Generate & download links",
|
||||
"generate_links_error": "Single use links could not get generated. Please work directly with the API",
|
||||
"multi_use_link": "Multi-use link",
|
||||
"multi_use_link_description": "Collect multiple responses from anonymous respondents with one link.",
|
||||
"multi_use_powers_other_channels_description": "If you disable it, these other distribution channels will also get disabled.",
|
||||
"multi_use_powers_other_channels_title": "This link powers Website embeds, Email embeds, Social media sharing and QR codes.",
|
||||
"multi_use_toggle_error": "Error enabling multi-use links, please try again later",
|
||||
"nav_title": "Anonymous links",
|
||||
"number_of_links_empty": "Number of links is required",
|
||||
"number_of_links_label": "Number of links (1 - 5,000)",
|
||||
"single_use_link": "Single-use links",
|
||||
"single_use_link_description": "Allow only one response per survey link.",
|
||||
"single_use_links": "Single-use links",
|
||||
"source_tracking": "Source tracking",
|
||||
"title": "Share your survey to gather responses",
|
||||
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
|
||||
"url_encryption_label": "URL encryption of single-use ID"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Edit survey",
|
||||
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
|
||||
"alert_title": "Change survey type to in-app",
|
||||
"attribute_based_targeting": "Attribute-based targeting",
|
||||
"code_no_code_triggers": "Code and no code triggers",
|
||||
"description": "Formbricks surveys can be embedded as a pop up, based on user interaction.",
|
||||
"docs_title": "Do more with intercept surveys",
|
||||
"nav_title": "Dynamic (Pop-up)",
|
||||
"recontact_options": "Recontact options",
|
||||
"title": "Intercept users in their flow to gather contextualized feedback"
|
||||
},
|
||||
"embed_on_website": {
|
||||
"description": "Formbricks surveys can be embedded as a static element.",
|
||||
"embed_code_copied_to_clipboard": "Embed code copied to clipboard!",
|
||||
"embed_in_an_email": "Embed in an email",
|
||||
"embed_in_app": "Embed in app",
|
||||
"embed_mode": "Embed Mode",
|
||||
"embed_mode_description": "Embed your survey with a minimalist design, discarding padding and background.",
|
||||
"nav_title": "Website embed",
|
||||
"title": "Embed the survey in your webpage"
|
||||
}
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
|
||||
"create_single_use_links": "Create single-use links",
|
||||
"create_single_use_links_description": "Accept only one submission per link. Here is how.",
|
||||
"description": "Generate personal links for a segment and match survey responses to each contact.",
|
||||
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
|
||||
"expiry_date_optional": "Expiry date (optional)",
|
||||
"generate_and_download_links": "Generate & download links",
|
||||
"generating_links": "Generating links",
|
||||
"generating_links_toast": "Generating links, download will start soon…",
|
||||
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
|
||||
"nav_title": "Personal links",
|
||||
"no_segments_available": "No segments available",
|
||||
"select_segment": "Select segment",
|
||||
"title": "Maximize insights with personal survey links",
|
||||
"upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
|
||||
"upgrade_prompt_title": "Use personal links with a higher plan",
|
||||
"work_with_segments": "Personal links work with segments."
|
||||
},
|
||||
"send_email": {
|
||||
"copy_embed_code": "Copy embed code",
|
||||
"description": "Embed your survey in an email to get responses from your audience.",
|
||||
"email_preview_tab": "Email Preview",
|
||||
"email_sent": "Email sent!",
|
||||
"email_subject_label": "Subject",
|
||||
"email_to_label": "To",
|
||||
"embed_code_copied_to_clipboard": "Embed code copied to clipboard!",
|
||||
"embed_code_copied_to_clipboard_failed": "Copy failed, please try again",
|
||||
"embed_code_tab": "Embed Code",
|
||||
"formbricks_email_survey_preview": "Formbricks Email Survey Preview",
|
||||
"nav_title": "Email embed",
|
||||
"send_preview": "Send preview",
|
||||
"send_preview_email": "Send preview email",
|
||||
"title": "Embed your survey in an email"
|
||||
},
|
||||
"share_view_title": "Share via"
|
||||
},
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ",
|
||||
@@ -1715,6 +1799,22 @@
|
||||
"all_responses_excel": "All responses (Excel)",
|
||||
"all_time": "All time",
|
||||
"almost_there": "Almost there! Install widget to start receiving responses.",
|
||||
"anonymous_links": "Anonymous links",
|
||||
"anonymous_links.custom_start_point": "Custom start point",
|
||||
"anonymous_links.data_prefilling": "Data prefilling",
|
||||
"anonymous_links.docs_title": "Do more with link surveys",
|
||||
"anonymous_links.multi_use_link": "Multi-use link",
|
||||
"anonymous_links.multi_use_link_alert_description": "If you disable it, these other distribution channels will also get disabled",
|
||||
"anonymous_links.multi_use_link_alert_title": "This link powers Website embeds, Email embeds, Social media sharing and QR codes",
|
||||
"anonymous_links.multi_use_link_description": "Collect multiple responses from anonymous respondents with one link",
|
||||
"anonymous_links.single_use_link": "Single-use link",
|
||||
"anonymous_links.single_use_link_description": "Allow only one response per survey link",
|
||||
"anonymous_links.single_use_link_encryption": "URL encryption of single-use ID",
|
||||
"anonymous_links.single_use_link_encryption_alert_description": "If you don’t encrypt single-use ID’s, any value for “suid=...” works for one response",
|
||||
"anonymous_links.single_use_link_encryption_description": "Only disable if you need to set a custom single-use ID",
|
||||
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Generate & download links",
|
||||
"anonymous_links.single_use_link_encryption_number_of_links": "Number of links (1 - 5,000)",
|
||||
"anonymous_links.source_tracking": "Source tracking",
|
||||
"average": "Average",
|
||||
"completed": "Completed",
|
||||
"completed_tooltip": "Number of times the survey has been completed.",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"response": "Réponse",
|
||||
"responses": "Réponses",
|
||||
"restart": "Redémarrer",
|
||||
"retry": "Réessayer",
|
||||
"role": "Rôle",
|
||||
"role_organization": "Rôle (Organisation)",
|
||||
"saas": "SaaS",
|
||||
@@ -1250,6 +1249,8 @@
|
||||
"add_description": "Ajouter une description",
|
||||
"add_ending": "Ajouter une fin",
|
||||
"add_ending_below": "Ajouter une fin ci-dessous",
|
||||
"add_fallback": "Ajouter",
|
||||
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
|
||||
"add_hidden_field_id": "Ajouter un champ caché ID",
|
||||
"add_highlight_border": "Ajouter une bordure de surlignage",
|
||||
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
|
||||
"everyone": "Tout le monde",
|
||||
"fallback_for": "Solution de repli pour ",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
|
||||
@@ -1699,14 +1701,96 @@
|
||||
"results_unpublished_successfully": "Résultats publiés avec succès.",
|
||||
"search_by_survey_name": "Recherche par nom d'enquête",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Si vous n’encryptez pas les identifiants à usage unique, toute valeur pour « suid=... » fonctionne pour une seule réponse",
|
||||
"custom_single_use_id_title": "Vous pouvez définir n'importe quelle valeur comme identifiant à usage unique dans l'URL.",
|
||||
"custom_start_point": "Point de départ personnalisé",
|
||||
"data_prefilling": "Préremplissage des données",
|
||||
"description": "Les réponses provenant de ces liens seront anonymes",
|
||||
"disable_multi_use_link_modal_button": "Désactiver le lien multi-usage",
|
||||
"disable_multi_use_link_modal_description": "La désactivation du lien multi-usage empêchera quiconque de soumettre une réponse via le lien.",
|
||||
"disable_multi_use_link_modal_description_one": "La désactivation du lien multi-usage empêchera quiconque de soumettre une réponse via le lien.",
|
||||
"disable_multi_use_link_modal_description_subtext": "Cela cassera également toutes les intégrations actives sur les sites Web, les emails, les réseaux sociaux et les codes QR qui utilisent ce lien multi-usage.",
|
||||
"disable_multi_use_link_modal_description_two": "Cela cassera également toutes les intégrations actives sur les sites web, les emails, les réseaux sociaux et les codes QR qui utilisent\nce lien multi-usage.",
|
||||
"disable_multi_use_link_modal_title": "Êtes-vous sûr ? Cela peut casser les intégrations actives.",
|
||||
"disable_single_use_link_modal_button": "Désactiver les liens à usage unique",
|
||||
"disable_single_use_link_modal_description": "Si vous avez partagé des liens à usage unique, les participants ne pourront plus répondre au sondage.",
|
||||
"generate_and_download_links": "Générer et télécharger les liens",
|
||||
"generate_links_error": "Les liens à usage unique n'ont pas pu être générés. Veuillez travailler directement avec l'API",
|
||||
"multi_use_link": "Lien multi-usage",
|
||||
"multi_use_link_description": "Recueillir plusieurs réponses de répondants anonymes avec un seul lien.",
|
||||
"multi_use_powers_other_channels_description": "Si vous le désactivez, ces autres canaux de distribution seront également désactivés.",
|
||||
"multi_use_powers_other_channels_title": "Ce lien alimente les intégrations du site Web, les intégrations de courrier électronique, le partage sur les réseaux sociaux et les codes QR.",
|
||||
"multi_use_toggle_error": "Erreur lors de l'activation des liens à usage multiple, veuillez réessayer plus tard",
|
||||
"nav_title": "Liens anonymes",
|
||||
"number_of_links_empty": "Le nombre de liens est requis",
|
||||
"number_of_links_label": "Nombre de liens (1 - 5,000)",
|
||||
"single_use_link": "Liens à usage unique",
|
||||
"single_use_link_description": "Autoriser uniquement une réponse par lien d'enquête",
|
||||
"single_use_links": "Liens à usage unique",
|
||||
"source_tracking": "Suivi des sources",
|
||||
"title": "Partagez votre enquête pour recueillir des réponses",
|
||||
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
|
||||
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Modifier enquête",
|
||||
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
|
||||
"alert_title": "Changer le type d'enquête en application intégrée",
|
||||
"attribute_based_targeting": "Ciblage basé sur des attributs",
|
||||
"code_no_code_triggers": "Déclencheurs avec et sans code",
|
||||
"description": "Les enquêtes Formbricks peuvent être intégrées sous forme de pop-up, en fonction de l'interaction de l'utilisateur.",
|
||||
"docs_title": "Faites plus avec les enquêtes d'interception",
|
||||
"nav_title": "Dynamique (Pop-up)",
|
||||
"recontact_options": "Options de recontact",
|
||||
"title": "Interceptez les utilisateurs dans leur flux pour recueillir des retours contextualisés"
|
||||
},
|
||||
"embed_on_website": {
|
||||
"description": "Les enquêtes Formbricks peuvent être intégrées comme élément statique.",
|
||||
"embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !",
|
||||
"embed_in_an_email": "Inclure dans un e-mail",
|
||||
"embed_in_app": "Intégrer dans l'application",
|
||||
"embed_mode": "Mode d'intégration",
|
||||
"embed_mode_description": "Intégrez votre enquête avec un design minimaliste, en supprimant les marges et l'arrière-plan.",
|
||||
"nav_title": "Incorporer sur le site web",
|
||||
"title": "Intégrez le sondage sur votre page web"
|
||||
}
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
|
||||
"create_single_use_links": "Créer des liens à usage unique",
|
||||
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
|
||||
"description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
|
||||
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
|
||||
"expiry_date_optional": "Date d'expiration (facultatif)",
|
||||
"generate_and_download_links": "Générer et télécharger les liens",
|
||||
"generating_links": "Génération de liens",
|
||||
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
|
||||
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
|
||||
"nav_title": "Liens personnels",
|
||||
"no_segments_available": "Aucun segment disponible",
|
||||
"select_segment": "Sélectionner le segment",
|
||||
"title": "Maximisez les insights avec des liens d'enquête personnels",
|
||||
"upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
|
||||
"upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
|
||||
"work_with_segments": "Les liens personnels fonctionnent avec les segments."
|
||||
},
|
||||
"send_email": {
|
||||
"copy_embed_code": "Copier le code d'intégration",
|
||||
"description": "Intégrez votre sondage dans un email pour obtenir des réponses de votre audience.",
|
||||
"email_preview_tab": "Aperçu de l'email",
|
||||
"email_sent": "Email envoyé !",
|
||||
"email_subject_label": "Sujet",
|
||||
"email_to_label": "à",
|
||||
"embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !",
|
||||
"embed_code_copied_to_clipboard_failed": "Échec de la copie, veuillez réessayer",
|
||||
"embed_code_tab": "Code d'intégration",
|
||||
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
|
||||
"nav_title": "Email intégré",
|
||||
"send_preview": "Envoyer un aperçu",
|
||||
"send_preview_email": "Envoyer un e-mail d'aperçu",
|
||||
"title": "Intégrez votre sondage dans un e-mail"
|
||||
},
|
||||
"share_view_title": "Partager par"
|
||||
},
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est '{'filterComboBoxValue'}' - '{'filterValue'}' ",
|
||||
@@ -1715,6 +1799,22 @@
|
||||
"all_responses_excel": "Tous les réponses (Excel)",
|
||||
"all_time": "Tout le temps",
|
||||
"almost_there": "Presque là ! Installez le widget pour commencer à recevoir des réponses.",
|
||||
"anonymous_links": "Liens anonymes",
|
||||
"anonymous_links.custom_start_point": "Point de départ personnalisé",
|
||||
"anonymous_links.data_prefilling": "Préremplissage des données",
|
||||
"anonymous_links.docs_title": "Faites plus avec les sondages par lien",
|
||||
"anonymous_links.multi_use_link": "Lien multi-usage",
|
||||
"anonymous_links.multi_use_link_alert_description": "Si vous le désactivez, ces autres canaux de distribution seront également désactivés",
|
||||
"anonymous_links.multi_use_link_alert_title": "Ce lien alimente les intégrations du site Web, les intégrations de courrier électronique, le partage sur les réseaux sociaux et les codes QR",
|
||||
"anonymous_links.multi_use_link_description": "Recueillir plusieurs réponses de répondants anonymes avec un seul lien",
|
||||
"anonymous_links.single_use_link": "Lien à usage unique",
|
||||
"anonymous_links.single_use_link_description": "Autoriser uniquement une réponse par lien d'enquête",
|
||||
"anonymous_links.single_use_link_encryption": "Cryptage de l'identifiant à usage unique dans l'URL",
|
||||
"anonymous_links.single_use_link_encryption_alert_description": "Si vous n’encryptez pas les identifiants à usage unique, toute valeur pour « suid=... » fonctionne pour une seule réponse",
|
||||
"anonymous_links.single_use_link_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
|
||||
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Générer et télécharger les liens",
|
||||
"anonymous_links.single_use_link_encryption_number_of_links": "Nombre de liens (1 - 5,000)",
|
||||
"anonymous_links.source_tracking": "Suivi des sources",
|
||||
"average": "Moyenne",
|
||||
"completed": "Terminé",
|
||||
"completed_tooltip": "Nombre de fois que l'enquête a été complétée.",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"response": "Resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Tentar novamente",
|
||||
"role": "Rolê",
|
||||
"role_organization": "Função (Organização)",
|
||||
"saas": "SaaS",
|
||||
@@ -1250,6 +1249,8 @@
|
||||
"add_description": "Adicionar Descrição",
|
||||
"add_ending": "Adicionar final",
|
||||
"add_ending_below": "Adicione o final abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar campo oculto ID",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todo mundo",
|
||||
"fallback_for": "Alternativa para",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
@@ -1699,14 +1701,96 @@
|
||||
"results_unpublished_successfully": "Resultados não publicados com sucesso.",
|
||||
"search_by_survey_name": "Buscar pelo nome da pesquisa",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Se você não criptografar ID’s de uso único, qualquer valor para “suid=...” funciona para uma resposta",
|
||||
"custom_single_use_id_title": "Você pode definir qualquer valor como ID de uso único na URL.",
|
||||
"custom_start_point": "Ponto de início personalizado",
|
||||
"data_prefilling": "preenchimento automático de dados",
|
||||
"description": "Respostas vindas desses links serão anônimas",
|
||||
"disable_multi_use_link_modal_button": "Desativar link de uso múltiplo",
|
||||
"disable_multi_use_link_modal_description": "Desativar o link de uso múltiplo impedirá que alguém envie uma resposta por meio do link.",
|
||||
"disable_multi_use_link_modal_description_one": "Desativar o link de uso múltiplo impedirá que alguém envie uma resposta por meio do link.",
|
||||
"disable_multi_use_link_modal_description_subtext": "Também quebrará quaisquer incorporações ativas em Sites, Emails, Mídias Sociais e códigos QR que usem esse link de uso múltiplo.",
|
||||
"disable_multi_use_link_modal_description_two": "Isso também quebrará quaisquer incorporações ativas em Sites, Emails, Mídias Sociais e códigos QR que usem\n esse link de uso múltiplo.",
|
||||
"disable_multi_use_link_modal_title": "Tem certeza? Isso pode quebrar incorporações ativas",
|
||||
"disable_single_use_link_modal_button": "Desativar links de uso único",
|
||||
"disable_single_use_link_modal_description": "Se você compartilhou links de uso único, os participantes não poderão mais responder à pesquisa.",
|
||||
"generate_and_download_links": "Gerar & baixar links",
|
||||
"generate_links_error": "Não foi possível gerar links de uso único. Por favor, trabalhe diretamente com a API",
|
||||
"multi_use_link": "Link de uso múltiplo",
|
||||
"multi_use_link_description": "Coletar múltiplas respostas de respondentes anônimos com um link.",
|
||||
"multi_use_powers_other_channels_description": "Se você desativar, esses outros canais de distribuição também serão desativados",
|
||||
"multi_use_powers_other_channels_title": "Este link habilita incorporações em sites, incorporações em e-mails, compartilhamento em redes sociais e códigos QR",
|
||||
"multi_use_toggle_error": "Erro ao habilitar links de uso múltiplo, tente novamente mais tarde",
|
||||
"nav_title": "Links anônimos",
|
||||
"number_of_links_empty": "O número de links é necessário",
|
||||
"number_of_links_label": "Número de links (1 - 5.000)",
|
||||
"single_use_link": "Links de uso único",
|
||||
"single_use_link_description": "Permitir apenas uma resposta por link da pesquisa.",
|
||||
"single_use_links": "Links de uso único",
|
||||
"source_tracking": "rastreamento de origem",
|
||||
"title": "Compartilhe sua pesquisa para coletar respostas",
|
||||
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
|
||||
"url_encryption_label": "Criptografia de URL de ID de uso único"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Editar pesquisa",
|
||||
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
|
||||
"alert_title": "Alterar o tipo de pesquisa para dentro do app",
|
||||
"attribute_based_targeting": "Segmentação baseada em atributos",
|
||||
"code_no_code_triggers": "Gatilhos de código e sem código",
|
||||
"description": "\"As pesquisas do Formbricks podem ser integradas como um pop-up, baseado na interação do usuário.\"",
|
||||
"docs_title": "Faça mais com pesquisas de interceptação",
|
||||
"nav_title": "Dinâmico (Pop-up)",
|
||||
"recontact_options": "Opções de Recontato",
|
||||
"title": "Intercepte os usuários em seu fluxo para coletar feedback contextualizado"
|
||||
},
|
||||
"embed_on_website": {
|
||||
"description": "Os formulários Formbricks podem ser incorporados como um elemento estático.",
|
||||
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
|
||||
"embed_in_an_email": "Incorporar em um e-mail",
|
||||
"embed_in_app": "Integrar no app",
|
||||
"embed_mode": "Modo Embutido",
|
||||
"embed_mode_description": "Incorpore sua pesquisa com um design minimalista, sem preenchimento e fundo.",
|
||||
"nav_title": "Incorporar no site",
|
||||
"title": "Incorporar a pesquisa na sua página da web"
|
||||
}
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
|
||||
"create_single_use_links": "Crie links de uso único",
|
||||
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
|
||||
"description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato.",
|
||||
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
|
||||
"expiry_date_optional": "Data de expiração (opcional)",
|
||||
"generate_and_download_links": "Gerar & baixar links",
|
||||
"generating_links": "Gerando links",
|
||||
"generating_links_toast": "Gerando links, o download começará em breve…",
|
||||
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
|
||||
"nav_title": "Links pessoais",
|
||||
"no_segments_available": "Nenhum segmento disponível",
|
||||
"select_segment": "Selecionar segmento",
|
||||
"title": "Maximize insights com links de pesquisa personalizados",
|
||||
"upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
|
||||
"upgrade_prompt_title": "Use links pessoais com um plano superior",
|
||||
"work_with_segments": "Links pessoais funcionam com segmentos."
|
||||
},
|
||||
"send_email": {
|
||||
"copy_embed_code": "Copiar código incorporado",
|
||||
"description": "Incorpore sua pesquisa em um e-mail para obter respostas do seu público.",
|
||||
"email_preview_tab": "Prévia do Email",
|
||||
"email_sent": "Email enviado!",
|
||||
"email_subject_label": "Assunto",
|
||||
"email_to_label": "Para",
|
||||
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
|
||||
"embed_code_copied_to_clipboard_failed": "Falha ao copiar, por favor, tente novamente",
|
||||
"embed_code_tab": "Código de Incorporação",
|
||||
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
|
||||
"nav_title": "Incorporação de Email",
|
||||
"send_preview": "Enviar prévia",
|
||||
"send_preview_email": "Enviar prévia de e-mail",
|
||||
"title": "Incorpore sua pesquisa em um e-mail"
|
||||
},
|
||||
"share_view_title": "Compartilhar via"
|
||||
},
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
|
||||
@@ -1715,6 +1799,22 @@
|
||||
"all_responses_excel": "Todas as respostas (Excel)",
|
||||
"all_time": "Todo o tempo",
|
||||
"almost_there": "Quase lá! Instale o widget para começar a receber respostas.",
|
||||
"anonymous_links": "Links anônimos",
|
||||
"anonymous_links.custom_start_point": "Ponto de início personalizado",
|
||||
"anonymous_links.data_prefilling": "preenchimento automático de dados",
|
||||
"anonymous_links.docs_title": "Faça mais com pesquisas de links",
|
||||
"anonymous_links.multi_use_link": "Link de uso múltiplo",
|
||||
"anonymous_links.multi_use_link_alert_description": "Se você desativar, esses outros canais de distribuição também serão desativados",
|
||||
"anonymous_links.multi_use_link_alert_title": "Este link permite a incorporação em sites, incorporações em e-mails, compartilhamento em redes sociais e códigos QR",
|
||||
"anonymous_links.multi_use_link_description": "Coletar múltiplas respostas de respondentes anônimos com um link",
|
||||
"anonymous_links.single_use_link": "Link de uso único",
|
||||
"anonymous_links.single_use_link_description": "Permitir apenas uma resposta por link da pesquisa.",
|
||||
"anonymous_links.single_use_link_encryption": "Criptografia de URL de ID de uso único",
|
||||
"anonymous_links.single_use_link_encryption_alert_description": "Se você não criptografar ID’s de uso único, qualquer valor para “suid=...” funciona para uma resposta",
|
||||
"anonymous_links.single_use_link_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
|
||||
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Gerar & baixar links",
|
||||
"anonymous_links.single_use_link_encryption_number_of_links": "Número de links (1 - 5.000)",
|
||||
"anonymous_links.source_tracking": "rastreamento de origem",
|
||||
"average": "média",
|
||||
"completed": "Concluído",
|
||||
"completed_tooltip": "Número de vezes que a pesquisa foi completada.",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"response": "Resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Repetir",
|
||||
"role": "Função",
|
||||
"role_organization": "Função (Organização)",
|
||||
"saas": "SaaS",
|
||||
@@ -1250,6 +1249,8 @@
|
||||
"add_description": "Adicionar descrição",
|
||||
"add_ending": "Adicionar encerramento",
|
||||
"add_ending_below": "Adicionar encerramento abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar ID do campo oculto",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todos",
|
||||
"fallback_for": "Alternativa para ",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
@@ -1699,14 +1701,96 @@
|
||||
"results_unpublished_successfully": "Resultados despublicados com sucesso.",
|
||||
"search_by_survey_name": "Pesquisar por nome do inquérito",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Se não encriptar os IDs de uso único, qualquer valor para “suid=...” funciona para uma resposta",
|
||||
"custom_single_use_id_title": "Pode definir qualquer valor como ID de uso único no URL.",
|
||||
"custom_start_point": "Ponto de início personalizado",
|
||||
"data_prefilling": "Pré-preenchimento de dados",
|
||||
"description": "Respostas provenientes destes links serão anónimas",
|
||||
"disable_multi_use_link_modal_button": "Desativar link de uso múltiplo",
|
||||
"disable_multi_use_link_modal_description": "Desativar o link de uso múltiplo impedirá que alguém submeta uma resposta através do link.",
|
||||
"disable_multi_use_link_modal_description_one": "Desativar o link de uso múltiplo impedirá que alguém submeta uma resposta através do link.",
|
||||
"disable_multi_use_link_modal_description_subtext": "Isto também irá quebrar quaisquer incorporações ativas em websites, emails, redes sociais e códigos QR que utilizem este link de uso múltiplo.",
|
||||
"disable_multi_use_link_modal_description_two": "Isto também irá afetar quaisquer incorporações ativas em websites, emails, redes sociais e códigos QR que utilizem\neste link de uso múltiplo.",
|
||||
"disable_multi_use_link_modal_title": "Tem a certeza? Isto pode afetar integrações ativas",
|
||||
"disable_single_use_link_modal_button": "Desativar links de uso único",
|
||||
"disable_single_use_link_modal_description": "Se partilhou links de uso único, os participantes já não poderão responder ao inquérito.",
|
||||
"generate_and_download_links": "Gerar & descarregar links",
|
||||
"generate_links_error": "Não foi possível gerar links de uso único. Por favor, trabalhe diretamente com a API",
|
||||
"multi_use_link": "Link de uso múltiplo",
|
||||
"multi_use_link_description": "Recolha múltiplas respostas de respondentes anónimos com um só link.",
|
||||
"multi_use_powers_other_channels_description": "Se desativar, estes outros canais de distribuição também serão desativados.",
|
||||
"multi_use_powers_other_channels_title": "Este link alimenta incorporações em Websites, incorporações em Email, partilha em Redes Sociais e Códigos QR.",
|
||||
"multi_use_toggle_error": "Erro ao ativar links de uso múltiplo, por favor tente novamente mais tarde",
|
||||
"nav_title": "Links anónimos",
|
||||
"number_of_links_empty": "Número de links é obrigatório",
|
||||
"number_of_links_label": "Número de links (1 - 5.000)",
|
||||
"single_use_link": "Links de uso único",
|
||||
"single_use_link_description": "Permitir apenas uma resposta por link de inquérito.",
|
||||
"single_use_links": "Links de uso único",
|
||||
"source_tracking": "Rastreamento de origem",
|
||||
"title": "Partilhe o seu inquérito para recolher respostas",
|
||||
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
|
||||
"url_encryption_label": "Encriptação do URL de ID de uso único"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Editar inquérito",
|
||||
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
|
||||
"alert_title": "Mudar tipo de inquérito para in-app",
|
||||
"attribute_based_targeting": "Segmentação baseada em atributos",
|
||||
"code_no_code_triggers": "Gatilhos com código e sem código",
|
||||
"description": "Os inquéritos Formbricks podem ser incorporados como uma janela pop-up, com base na interação do utilizador.",
|
||||
"docs_title": "Faça mais com sondagens de interceptação",
|
||||
"nav_title": "Dinâmico (Pop-up)",
|
||||
"recontact_options": "Opções de Recontacto",
|
||||
"title": "Intercepte utilizadores no seu fluxo para recolher feedback contextualizado"
|
||||
},
|
||||
"embed_on_website": {
|
||||
"description": "Os inquéritos Formbricks podem ser incorporados como um elemento estático.",
|
||||
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
|
||||
"embed_in_an_email": "Incorporar num email",
|
||||
"embed_in_app": "Incorporar na aplicação",
|
||||
"embed_mode": "Modo de Incorporação",
|
||||
"embed_mode_description": "Incorpore o seu inquérito com um design minimalista, descartando o preenchimento e o fundo.",
|
||||
"nav_title": "Incorporar no site",
|
||||
"title": "Incorporar o questionário na sua página web"
|
||||
}
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
|
||||
"create_single_use_links": "Criar links de uso único",
|
||||
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
|
||||
"description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
|
||||
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
|
||||
"expiry_date_optional": "Data de expiração (opcional)",
|
||||
"generate_and_download_links": "Gerar & descarregar links",
|
||||
"generating_links": "Gerando links",
|
||||
"generating_links_toast": "A gerar links, o download começará em breve…",
|
||||
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
|
||||
"nav_title": "Links pessoais",
|
||||
"no_segments_available": "Sem segmentos disponíveis",
|
||||
"select_segment": "Selecionar segmento",
|
||||
"title": "Maximize os insights com links pessoais de inquérito",
|
||||
"upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
|
||||
"upgrade_prompt_title": "Utilize links pessoais com um plano superior",
|
||||
"work_with_segments": "Os links pessoais funcionam com segmentos."
|
||||
},
|
||||
"send_email": {
|
||||
"copy_embed_code": "Copiar código de incorporação",
|
||||
"description": "Incorpora o teu inquérito num email para obter respostas do teu público.",
|
||||
"email_preview_tab": "Pré-visualização de Email",
|
||||
"email_sent": "Email enviado!",
|
||||
"email_subject_label": "Assunto",
|
||||
"email_to_label": "Para",
|
||||
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
|
||||
"embed_code_copied_to_clipboard_failed": "A cópia falhou, por favor, tente novamente",
|
||||
"embed_code_tab": "Código de Incorporação",
|
||||
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
|
||||
"nav_title": "Incorporação de Email",
|
||||
"send_preview": "Enviar pré-visualização",
|
||||
"send_preview_email": "Enviar pré-visualização de email",
|
||||
"title": "Incorporar o seu inquérito num email"
|
||||
},
|
||||
"share_view_title": "Partilhar via"
|
||||
},
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
|
||||
@@ -1715,6 +1799,22 @@
|
||||
"all_responses_excel": "Todas as respostas (Excel)",
|
||||
"all_time": "Todo o tempo",
|
||||
"almost_there": "Quase lá! Instale o widget para começar a receber respostas.",
|
||||
"anonymous_links": "Links anónimos",
|
||||
"anonymous_links.custom_start_point": "Ponto de início personalizado",
|
||||
"anonymous_links.data_prefilling": "Pré-preenchimento de dados",
|
||||
"anonymous_links.docs_title": "Faça mais com inquéritos de ligação",
|
||||
"anonymous_links.multi_use_link": "Link de uso múltiplo",
|
||||
"anonymous_links.multi_use_link_alert_description": "Se desativar, estes outros canais de distribuição também serão desativados",
|
||||
"anonymous_links.multi_use_link_alert_title": "Este link alimenta incorporações em Websites, incorporações em Email, partilha em Redes Sociais e Códigos QR",
|
||||
"anonymous_links.multi_use_link_description": "Recolha múltiplas respostas de respondentes anónimos com um só link",
|
||||
"anonymous_links.single_use_link": "Link de uso único",
|
||||
"anonymous_links.single_use_link_description": "Permitir apenas uma resposta por link de inquérito",
|
||||
"anonymous_links.single_use_link_encryption": "Encriptação do URL de ID de uso único",
|
||||
"anonymous_links.single_use_link_encryption_alert_description": "Se não encriptar os IDs de uso único, qualquer valor para 'suid=...' funciona para uma resposta",
|
||||
"anonymous_links.single_use_link_encryption_description": "Desativar apenas se precisar definir um ID de uso único personalizado",
|
||||
"anonymous_links.single_use_link_encryption_generate_and_download_links": "Gerar & descarregar links",
|
||||
"anonymous_links.single_use_link_encryption_number_of_links": "Número de links (1 - 5.000)",
|
||||
"anonymous_links.source_tracking": "Rastreamento de origem",
|
||||
"average": "Média",
|
||||
"completed": "Concluído",
|
||||
"completed_tooltip": "Número de vezes que o inquérito foi concluído.",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"response": "回應",
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"retry": "重 試",
|
||||
"role": "角色",
|
||||
"role_organization": "角色(組織)",
|
||||
"saas": "SaaS",
|
||||
@@ -1250,6 +1249,8 @@
|
||||
"add_description": "新增描述",
|
||||
"add_ending": "新增結尾",
|
||||
"add_ending_below": "在下方新增結尾",
|
||||
"add_fallback": "新增",
|
||||
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
|
||||
"add_hidden_field_id": "新增隱藏欄位 ID",
|
||||
"add_highlight_border": "新增醒目提示邊框",
|
||||
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
|
||||
"everyone": "所有人",
|
||||
"fallback_for": "備用 用於 ",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
|
||||
@@ -1699,14 +1701,96 @@
|
||||
"results_unpublished_successfully": "結果已成功取消發布。",
|
||||
"search_by_survey_name": "依問卷名稱搜尋",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "如果您不加密 使用一次 的 ID,任何“ suid=...”的值都能用于 一次回應",
|
||||
"custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID",
|
||||
"custom_start_point": "自訂 開始 點",
|
||||
"data_prefilling": "資料預先填寫",
|
||||
"description": "從 這些 連結 獲得 的 回應 將是 匿名 的",
|
||||
"disable_multi_use_link_modal_button": "禁用 多 重 使用 連結",
|
||||
"disable_multi_use_link_modal_description": "停用多次使用連結將阻止任何人通過該連結提交回應。",
|
||||
"disable_multi_use_link_modal_description_one": "停用多次使用連結將阻止任何人通過該連結提交回應。",
|
||||
"disable_multi_use_link_modal_description_subtext": "這也會破壞在 網頁 、 電子郵件 、社交媒體 和 QR碼上使用此多次使用連結的任何 活動 嵌入 。",
|
||||
"disable_multi_use_link_modal_description_two": "這也會破壞在網頁、電子郵件、社交媒體和 QR碼上使用此多次使用連結的任何 活動 嵌入。",
|
||||
"disable_multi_use_link_modal_title": "您確定嗎?這可能會破壞 活動 嵌入 ",
|
||||
"disable_single_use_link_modal_button": "停用 單次使用連結",
|
||||
"disable_single_use_link_modal_description": "如果您共享了單次使用連結,參與者將不再能夠回應此問卷。",
|
||||
"generate_and_download_links": "生成 & 下載 連結",
|
||||
"generate_links_error": "無法生成單次使用連結。請直接使用 API",
|
||||
"multi_use_link": "多 重 使用 連結",
|
||||
"multi_use_link_description": "收集 多位 匿名 受訪者 的 多次 回應 , 使用 一個 連結",
|
||||
"multi_use_powers_other_channels_description": "如果您停用它,這些其他分發管道也會被停用",
|
||||
"multi_use_powers_other_channels_title": "這個 連結 支援 網站 嵌入 、 電子郵件 嵌入 、 社交 媒體 分享 和 QR 碼",
|
||||
"multi_use_toggle_error": "啟用多 重 使用 連結時出 錯 , 請稍候再試",
|
||||
"nav_title": "匿名 連結",
|
||||
"number_of_links_empty": "需要輸入連結數量",
|
||||
"number_of_links_label": "連結數量 (1 - 5,000)",
|
||||
"single_use_link": "單次使用連結",
|
||||
"single_use_link_description": "只允許 1 個回應每個問卷連結。",
|
||||
"single_use_links": "單次使用連結",
|
||||
"source_tracking": "來源追蹤",
|
||||
"title": "分享 您 的 調查來 收集 回應",
|
||||
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
|
||||
"url_encryption_label": "單次使用 ID 的 URL 加密"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "編輯 問卷",
|
||||
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
|
||||
"alert_title": "更改問卷類型為 in-app",
|
||||
"attribute_based_targeting": "屬性 基於 的 定位",
|
||||
"code_no_code_triggers": "程式碼 及 無程式碼 觸發器",
|
||||
"description": "Formbricks 調查 可以 嵌入 為 彈出 式 樣 式 , 根據 使用者 互動 。",
|
||||
"docs_title": "使用 截圖 調查 來 完成 更多 工作",
|
||||
"nav_title": "動態(彈窗)",
|
||||
"recontact_options": "重新聯絡選項",
|
||||
"title": "攔截使用者於其流程中以收集具上下文的意見反饋"
|
||||
},
|
||||
"embed_on_website": {
|
||||
"description": "Formbricks 調查可以 作為 靜態 元素 嵌入。",
|
||||
"embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!",
|
||||
"embed_in_an_email": "嵌入電子郵件中",
|
||||
"embed_in_app": "嵌入應用程式",
|
||||
"embed_mode": "嵌入模式",
|
||||
"embed_mode_description": "以簡約設計嵌入您的問卷,捨棄邊距和背景。",
|
||||
"nav_title": "嵌入網站",
|
||||
"title": "嵌入 調查 在 您 的 網頁"
|
||||
}
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
|
||||
"create_single_use_links": "建立單次使用連結",
|
||||
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
|
||||
"description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。",
|
||||
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
|
||||
"expiry_date_optional": "到期日 (可選)",
|
||||
"generate_and_download_links": "生成 & 下載 連結",
|
||||
"generating_links": "生成 連結",
|
||||
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
|
||||
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
|
||||
"nav_title": "個人 連結",
|
||||
"no_segments_available": "沒有可用的區段",
|
||||
"select_segment": "選擇 區隔",
|
||||
"title": "透過個人化調查連結最大化洞察",
|
||||
"upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
|
||||
"upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
|
||||
"work_with_segments": "個人 連結 可 與 分段 一起 使用"
|
||||
},
|
||||
"send_email": {
|
||||
"copy_embed_code": "複製嵌入程式碼",
|
||||
"description": "將 你的 調查 嵌入 在 電子郵件 中 以 獲得 觀眾 的 回應。",
|
||||
"email_preview_tab": "電子郵件預覽",
|
||||
"email_sent": "已發送電子郵件!",
|
||||
"email_subject_label": "主旨",
|
||||
"email_to_label": "收件者",
|
||||
"embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!",
|
||||
"embed_code_copied_to_clipboard_failed": "複製失敗,請再試一次",
|
||||
"embed_code_tab": "嵌入程式碼",
|
||||
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
|
||||
"nav_title": "電子郵件嵌入",
|
||||
"send_preview": "發送預覽",
|
||||
"send_preview_email": "發送預覽電子郵件",
|
||||
"title": "嵌入 你的 調查 在 電子郵件 中"
|
||||
},
|
||||
"share_view_title": "透過 分享"
|
||||
},
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'",
|
||||
@@ -1715,6 +1799,22 @@
|
||||
"all_responses_excel": "所有回應 (Excel)",
|
||||
"all_time": "全部時間",
|
||||
"almost_there": "快完成了!安裝小工具以開始接收回應。",
|
||||
"anonymous_links": "匿名 連結",
|
||||
"anonymous_links.custom_start_point": "自訂 開始 點",
|
||||
"anonymous_links.data_prefilling": "資料預先填寫",
|
||||
"anonymous_links.docs_title": "使用 連結 問卷 來 完成 更多 事情",
|
||||
"anonymous_links.multi_use_link": "多 重 使用 連結",
|
||||
"anonymous_links.multi_use_link_alert_description": "如果您停用它,這些其他分發管道也會被停用",
|
||||
"anonymous_links.multi_use_link_alert_title": "這個 連結 支援 網站 嵌入 、 電子郵件 嵌入 、 社交 媒體 分享 和 QR 碼",
|
||||
"anonymous_links.multi_use_link_description": "收集 多位 匿名 受訪者 的 多次 回應 , 使用 一個 連結",
|
||||
"anonymous_links.single_use_link": "單次使用連結",
|
||||
"anonymous_links.single_use_link_description": "只允許 1 個回應每個問卷連結。",
|
||||
"anonymous_links.single_use_link_encryption": "單次使用 ID 的 URL 加密",
|
||||
"anonymous_links.single_use_link_encryption_alert_description": "如果您不加密 使用一次 的 ID,任何“ suid=...”的值都能用于 一次回應",
|
||||
"anonymous_links.single_use_link_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
|
||||
"anonymous_links.single_use_link_encryption_generate_and_download_links": "生成 & 下載 連結",
|
||||
"anonymous_links.single_use_link_encryption_number_of_links": "連結數量 (1 - 5,000)",
|
||||
"anonymous_links.source_tracking": "來源追蹤",
|
||||
"average": "平均",
|
||||
"completed": "已完成",
|
||||
"completed_tooltip": "問卷已完成的次數。",
|
||||
|
||||
@@ -30,7 +30,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<button
|
||||
key={surveyLanguage.language.code}
|
||||
className="rounded-md p-2 hover:cursor-pointer hover:bg-slate-700"
|
||||
className="w-full rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
setLanguage(surveyLanguage.language.code);
|
||||
setShowLanguageSelect(false);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
<Input
|
||||
data-testid="survey-url-input"
|
||||
autoFocus={true}
|
||||
className="h-9 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
|
||||
className="h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
|
||||
value={surveyUrl}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
@@ -1,209 +1,246 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink/index";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ShareSurveyLink } from "./index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
const dummySurvey = {
|
||||
id: "survey123",
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const survey: TSurvey = {
|
||||
id: "survey-id",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
status: "completed",
|
||||
} as any;
|
||||
const dummyPublicDomain = "http://dummy.com";
|
||||
const dummyLocale = "en-US";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
generateSingleUseIdAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/client-utils", () => ({
|
||||
copySurveyLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((error: any) => error.message),
|
||||
}));
|
||||
|
||||
vi.mock("./components/LanguageDropdown", () => {
|
||||
const React = require("react");
|
||||
return {
|
||||
LanguageDropdown: (props: { setLanguage: (lang: string) => void }) => {
|
||||
// Call setLanguage("fr-FR") when the component mounts to simulate a language change.
|
||||
React.useEffect(() => {
|
||||
props.setLanguage("fr-FR");
|
||||
}, [props.setLanguage]);
|
||||
return <div>Mocked LanguageDropdown</div>;
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question-id",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question headline" },
|
||||
subheader: { default: "Question subheader" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
};
|
||||
],
|
||||
recontactDays: 1,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
displayLimit: null,
|
||||
triggers: [],
|
||||
redirectUrl: null,
|
||||
numDisplays: 0,
|
||||
numDisplaysGlobally: 0,
|
||||
numResponses: 0,
|
||||
numResponsesGlobally: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "lang-1",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "proj-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "lang-2",
|
||||
code: "de",
|
||||
alias: "German",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "proj-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
styling: null,
|
||||
variables: [],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
productOverwrites: null,
|
||||
resultShareKey: null,
|
||||
pin: null,
|
||||
verifyEmail: null,
|
||||
attributeFilters: [],
|
||||
autoComplete: null,
|
||||
hiddenFields: { enabled: true },
|
||||
environmentId: "env-id",
|
||||
endings: [],
|
||||
displayOption: "displayOnce",
|
||||
isBackButtonHidden: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
segment: null,
|
||||
showLanguageSwitch: false,
|
||||
createdBy: "user-id",
|
||||
followUps: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const publicDomain = "http://localhost:3000";
|
||||
let surveyUrl = `${publicDomain}/s/survey-id`;
|
||||
const setSurveyUrl = vi.fn((url: string) => {
|
||||
surveyUrl = url;
|
||||
});
|
||||
const locale: TUserLocale = "en-US";
|
||||
|
||||
// Mocking dependencies
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
global.open = vi.fn();
|
||||
|
||||
describe("ShareSurveyLink", () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
window.open = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
surveyUrl = `${publicDomain}/s/survey-id`;
|
||||
});
|
||||
|
||||
test("calls getUrl on mount and sets surveyUrl accordingly with singleUse enabled and default language", async () => {
|
||||
// Inline mocks for this test
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
|
||||
|
||||
const setSurveyUrl = vi.fn();
|
||||
test("renders the component with initial values", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl=""
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(setSurveyUrl).toHaveBeenCalled();
|
||||
});
|
||||
const url = setSurveyUrl.mock.calls[0][0];
|
||||
expect(url).toContain(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
|
||||
expect(url).not.toContain("lang=");
|
||||
});
|
||||
|
||||
test("appends language query when language is changed from default", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
|
||||
|
||||
const setSurveyUrl = vi.fn();
|
||||
const DummyWrapper = () => (
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl="initial"
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale="fr-FR"
|
||||
/>
|
||||
);
|
||||
render(<DummyWrapper />);
|
||||
await waitFor(() => {
|
||||
const generatedUrl = setSurveyUrl.mock.calls[1][0];
|
||||
expect(generatedUrl).toContain("lang=fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
test("preview button opens new window with preview query", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
|
||||
|
||||
const setSurveyUrl = vi.fn().mockReturnValue(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
);
|
||||
const previewButton = await screen.findByRole("button", {
|
||||
name: /environments.surveys.preview_survey_in_a_new_tab/i,
|
||||
});
|
||||
fireEvent.click(previewButton);
|
||||
await waitFor(() => {
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const previewUrl = vi.mocked(window.open).mock.calls[0][0];
|
||||
expect(previewUrl).toMatch(/\?suId=dummySuId(&|\\?)preview=true/);
|
||||
});
|
||||
});
|
||||
|
||||
test("copy button writes surveyUrl to clipboard and shows toast", async () => {
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
|
||||
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
|
||||
|
||||
const setSurveyUrl = vi.fn();
|
||||
const surveyUrl = `${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`;
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
publicDomain={dummyPublicDomain}
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
const copyButton = await screen.findByRole("button", {
|
||||
name: /environments.surveys.copy_survey_link_to_clipboard/i,
|
||||
});
|
||||
fireEvent.click(copyButton);
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue(surveyUrl)).toBeInTheDocument();
|
||||
expect(screen.getByText("common.copy")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders regenerate button when survey.singleUse.enabled is true and calls generateNewSingleUseLink", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
|
||||
|
||||
const setSurveyUrl = vi.fn();
|
||||
test("copies the survey link to the clipboard when copy button is clicked", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
const regenButton = await screen.findByRole("button", { name: /Regenerate single use survey link/i });
|
||||
fireEvent.click(regenButton);
|
||||
await waitFor(() => {
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.new_single_use_link_generated");
|
||||
});
|
||||
|
||||
const copyButton = screen.getByLabelText("environments.surveys.copy_survey_link_to_clipboard");
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("handles error when generating single-use link fails", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: undefined });
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("Failed to generate link");
|
||||
|
||||
const setSurveyUrl = vi.fn();
|
||||
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
publicDomain={dummyPublicDomain}
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
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)", () => {
|
||||
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyWithParamsUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank");
|
||||
});
|
||||
|
||||
test("disables copy and preview buttons when surveyUrl is empty", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl=""
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to generate link");
|
||||
});
|
||||
const copyButton = screen.getByLabelText("environments.surveys.copy_survey_link_to_clipboard");
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
|
||||
expect(copyButton).toBeDisabled();
|
||||
expect(previewButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("updates the survey URL when the language is changed", () => {
|
||||
const { rerender } = render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
const languageDropdown = screen.getByTitle("Select Language");
|
||||
fireEvent.click(languageDropdown);
|
||||
|
||||
const germanOption = screen.getByText("German");
|
||||
fireEvent.click(germanOption);
|
||||
|
||||
rerender(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
expect(setSurveyUrl).toHaveBeenCalled();
|
||||
expect(surveyUrl).toContain("lang=de");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Copy, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -28,38 +26,29 @@ export const ShareSurveyLink = ({
|
||||
locale,
|
||||
}: ShareSurveyLinkProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [language, setLanguage] = useState("default");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, publicDomain, language);
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
const errorMessage = getFormattedErrorMessage(error);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, language, publicDomain, setSurveyUrl]);
|
||||
|
||||
const generateNewSingleUseLink = async () => {
|
||||
try {
|
||||
const newUrl = await getSurveyUrl(survey, publicDomain, language);
|
||||
setSurveyUrl(newUrl);
|
||||
toast.success(t("environments.surveys.new_single_use_link_generated"));
|
||||
} catch (error) {
|
||||
const errorMessage = getFormattedErrorMessage(error);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
const handleLanguageChange = (language: string) => {
|
||||
const url = getSurveyUrl(survey, publicDomain, language);
|
||||
setSurveyUrl(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex max-w-full flex-col items-center justify-center gap-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
|
||||
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
|
||||
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
|
||||
<Button
|
||||
disabled={!surveyUrl}
|
||||
variant="secondary"
|
||||
title={t("environments.surveys.copy_survey_link_to_clipboard")}
|
||||
aria-label={t("environments.surveys.copy_survey_link_to_clipboard")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}>
|
||||
{t("common.copy")}
|
||||
<Copy />
|
||||
</Button>
|
||||
<Button
|
||||
title={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
@@ -76,27 +65,6 @@ export const ShareSurveyLink = ({
|
||||
{t("common.preview")}
|
||||
<SquareArrowOutUpRight />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!surveyUrl}
|
||||
variant="secondary"
|
||||
title={t("environments.surveys.copy_survey_link_to_clipboard")}
|
||||
aria-label={t("environments.surveys.copy_survey_link_to_clipboard")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}>
|
||||
{t("common.copy")}
|
||||
<Copy />
|
||||
</Button>
|
||||
{survey.singleUse?.enabled && (
|
||||
<Button
|
||||
disabled={!surveyUrl}
|
||||
title="Regenerate single use survey link"
|
||||
aria-label="Regenerate single use survey link"
|
||||
onClick={generateNewSingleUseLink}>
|
||||
<RefreshCcw className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { JSX } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -30,28 +28,10 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getSurveyUrl = async (
|
||||
survey: TSurvey,
|
||||
publicDomain: string,
|
||||
language: string
|
||||
): Promise<string> => {
|
||||
export const getSurveyUrl = (survey: TSurvey, publicDomain: string, language: string): string => {
|
||||
let url = `${publicDomain}/s/${survey.id}`;
|
||||
const queryParams: string[] = [];
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const singleUseIdResponse = await generateSingleUseIdAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: survey.singleUse.isEncrypted,
|
||||
});
|
||||
|
||||
if (singleUseIdResponse?.data) {
|
||||
queryParams.push(`suId=${singleUseIdResponse.data}`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(singleUseIdResponse);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (language !== "default") {
|
||||
queryParams.push(`lang=${language}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
@@ -8,7 +8,7 @@ import { useSingleUseId } from "./useSingleUseId";
|
||||
|
||||
// Mock external functions
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
generateSingleUseIdAction: vi.fn().mockResolvedValue({ data: "initialId" }),
|
||||
generateSingleUseIdsAction: vi.fn().mockResolvedValue({ data: ["initialId"] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
@@ -32,7 +32,7 @@ describe("useSingleUseId", () => {
|
||||
} as TSurvey;
|
||||
|
||||
test("should initialize singleUseId to undefined", () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "mockSingleUseId" });
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["mockSingleUseId"] });
|
||||
|
||||
const { result } = renderHook(() => useSingleUseId(mockSurvey));
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("useSingleUseId", () => {
|
||||
});
|
||||
|
||||
test("should fetch and set singleUseId if singleUse is enabled", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "mockSingleUseId" });
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["mockSingleUseId"] });
|
||||
|
||||
const { result, rerender } = renderHook((props) => useSingleUseId(props), {
|
||||
initialProps: mockSurvey,
|
||||
@@ -52,9 +52,10 @@ describe("useSingleUseId", () => {
|
||||
expect(result.current.singleUseId).toBe("mockSingleUseId");
|
||||
});
|
||||
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalledWith({
|
||||
expect(generateSingleUseIdsAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey123",
|
||||
isEncrypted: true,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// Re-render with the same props to ensure it doesn't break
|
||||
@@ -80,11 +81,11 @@ describe("useSingleUseId", () => {
|
||||
expect(result.current.singleUseId).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(generateSingleUseIdAction).not.toHaveBeenCalled();
|
||||
expect(generateSingleUseIdsAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should show toast error if the API call fails", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ serverError: "Something went wrong" });
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ serverError: "Something went wrong" });
|
||||
|
||||
const { result } = renderHook(() => useSingleUseId(mockSurvey));
|
||||
|
||||
@@ -98,19 +99,19 @@ describe("useSingleUseId", () => {
|
||||
|
||||
test("should refreshSingleUseId on demand", async () => {
|
||||
// Set up the initial mock response
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "initialId" });
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["initialId"] });
|
||||
|
||||
const { result } = renderHook(() => useSingleUseId(mockSurvey));
|
||||
|
||||
// We need to wait for the initial async effect to complete
|
||||
// This ensures the hook has time to update state with the first mock value
|
||||
await waitFor(() => {
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalledTimes(1);
|
||||
expect(generateSingleUseIdsAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Reset the mock and set up the next response for refreshSingleUseId call
|
||||
vi.mocked(generateSingleUseIdAction).mockClear();
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "refreshedId" });
|
||||
vi.mocked(generateSingleUseIdsAction).mockClear();
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["refreshedId"] });
|
||||
|
||||
// Call refreshSingleUseId and wait for it to complete
|
||||
let refreshedValue;
|
||||
@@ -125,9 +126,10 @@ describe("useSingleUseId", () => {
|
||||
expect(result.current.singleUseId).toBe("refreshedId");
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalledWith({
|
||||
expect(generateSingleUseIdsAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey123",
|
||||
isEncrypted: true,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
|
||||
import { TSurvey as TSurveyList } from "@/modules/survey/list/types/surveys";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -12,13 +12,15 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList) => {
|
||||
|
||||
const refreshSingleUseId = useCallback(async () => {
|
||||
if (survey.singleUse?.enabled) {
|
||||
const response = await generateSingleUseIdAction({
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: !!survey.singleUse?.isEncrypted,
|
||||
count: 1,
|
||||
});
|
||||
if (response?.data) {
|
||||
setSingleUseId(response.data);
|
||||
return response.data;
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
setSingleUseId(response.data[0]);
|
||||
return response.data[0];
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
|
||||
import { getUserProjects } from "@/modules/survey/list/lib/project";
|
||||
@@ -191,9 +191,10 @@ export const deleteSurveyAction = authenticatedActionClient.schema(ZDeleteSurvey
|
||||
const ZGenerateSingleUseIdAction = z.object({
|
||||
surveyId: z.string().cuid2(),
|
||||
isEncrypted: z.boolean(),
|
||||
count: z.number().min(1).max(5000).default(1),
|
||||
});
|
||||
|
||||
export const generateSingleUseIdAction = authenticatedActionClient
|
||||
export const generateSingleUseIdsAction = authenticatedActionClient
|
||||
.schema(ZGenerateSingleUseIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
@@ -212,7 +213,7 @@ export const generateSingleUseIdAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
return generateSurveySingleUseId(parsedInput.isEncrypted);
|
||||
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
|
||||
});
|
||||
|
||||
const ZGetSurveysAction = z.object({
|
||||
|
||||
@@ -38,9 +38,10 @@ export const AdvancedOptionToggle = ({
|
||||
</div>
|
||||
{children && isChecked && (
|
||||
<div
|
||||
className={`mt-4 flex w-full items-center space-x-1 rounded-lg ${
|
||||
childBorder ? "border" : ""
|
||||
} bg-slate-50`}>
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center space-x-1 overflow-hidden rounded-lg bg-slate-50",
|
||||
childBorder && "border"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2Icon, Info } from "lucide-react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
AlertTriangleIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckCircle2Icon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Button, ButtonProps } from "../button";
|
||||
|
||||
// Create a context to share variant and size with child components
|
||||
interface AlertContextValue {
|
||||
variant?: "default" | "error" | "warning" | "info" | "success" | null;
|
||||
variant?: "default" | "error" | "warning" | "info" | "success" | "outbound" | null;
|
||||
size?: "default" | "small" | null;
|
||||
}
|
||||
|
||||
@@ -21,10 +27,11 @@ const AlertContext = createContext<AlertContextValue>({
|
||||
const useAlertContext = () => useContext(AlertContext);
|
||||
|
||||
// Define alert styles with variants
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 bg-white", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-foreground border-border",
|
||||
outbound: "text-foreground border-border",
|
||||
error:
|
||||
"text-error-foreground [&>svg]:text-error border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted [&_a]:bg-error-background [&_a]:text-error-foreground [&_a:hover]:bg-error-background-muted",
|
||||
warning:
|
||||
@@ -46,11 +53,15 @@ const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
|
||||
},
|
||||
});
|
||||
|
||||
const alertVariantIcons: Record<"default" | "error" | "warning" | "info" | "success", React.ReactNode> = {
|
||||
const alertVariantIcons: Record<
|
||||
"default" | "error" | "warning" | "info" | "success" | "outbound",
|
||||
React.ReactNode
|
||||
> = {
|
||||
default: null,
|
||||
error: <AlertCircle className="size-4" />,
|
||||
warning: <AlertTriangle className="size-4" />,
|
||||
info: <Info className="size-4" />,
|
||||
outbound: <ArrowUpRightIcon className="size-4" />,
|
||||
error: <AlertCircleIcon className="size-4" />,
|
||||
warning: <AlertTriangleIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
success: <CheckCircle2Icon className="size-4" />,
|
||||
};
|
||||
|
||||
@@ -140,4 +151,4 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
AlertButton.displayName = "AlertButton";
|
||||
|
||||
// Export the new component
|
||||
export { Alert, AlertTitle, AlertDescription, AlertButton };
|
||||
export { Alert, AlertButton, AlertDescription, AlertTitle };
|
||||
|
||||
@@ -32,7 +32,7 @@ export const CodeBlock = ({
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className={cn("group relative rounded-md text-sm text-slate-200", noMargin ? "" : "mt-4")}>
|
||||
<div className={cn("group relative w-full rounded-md text-xs", noMargin ? "" : "mt-4")}>
|
||||
{showCopyToClipboard && (
|
||||
<div className="absolute right-2 top-2 z-20 flex cursor-pointer items-center justify-center p-1.5 text-slate-500 hover:text-slate-900">
|
||||
<CopyIcon
|
||||
@@ -46,7 +46,7 @@ export const CodeBlock = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<pre className={customEditorClass}>
|
||||
<pre className={cn("w-full overflow-x-auto rounded-lg", customEditorClass)}>
|
||||
<code className={cn(`language-${language} whitespace-pre-wrap`, customCodeClass)}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal transition-all ease-in hover:bg-slate-300",
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
|
||||
!formattedDate && "text-muted-foreground bg-slate-800"
|
||||
)}
|
||||
ref={btnRef}>
|
||||
@@ -80,7 +80,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal hover:bg-slate-300",
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
|
||||
!formattedDate && "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
@@ -124,7 +124,7 @@ export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: Dat
|
||||
</Popover>
|
||||
{formattedDate && onClearDate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearDate}
|
||||
className="h-8 w-8 p-0 hover:bg-slate-200">
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
)}
|
||||
aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<div className="flex h-full flex-1 justify-center px-3 py-2" key={tab.id}>
|
||||
<div className="flex h-full flex-1 justify-center p-1" key={tab.id}>
|
||||
<button
|
||||
onClick={() => !disabled && setActiveId(tab.id)}
|
||||
type="button"
|
||||
@@ -61,7 +61,7 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
tab.id === activeId
|
||||
? `bg-white font-semibold text-slate-900 ${activeTabClassName}`
|
||||
: "text-slate-500",
|
||||
"h-full w-full items-center rounded-lg text-center text-sm font-medium",
|
||||
"h-full w-full items-center rounded-md text-center text-sm font-medium",
|
||||
disabled ? "cursor-not-allowed" : "hover:text-slate-700"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
|
||||
@@ -39,8 +39,9 @@ describe("Typography Components", () => {
|
||||
expect(h3Element).toBeInTheDocument();
|
||||
expect(h3Element).toHaveTextContent("Heading 3");
|
||||
expect(h3Element?.className).toContain("text-lg");
|
||||
expect(h3Element?.className).toContain("tracking-tight");
|
||||
expect(h3Element?.className).toContain("text-slate-800");
|
||||
expect(h3Element?.className).toContain("font-medium");
|
||||
expect(h3Element?.className).toContain("scroll-m-20");
|
||||
});
|
||||
|
||||
test("renders H4 correctly", () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ const H3 = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElemen
|
||||
<h3
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn("scroll-m-20 text-lg tracking-tight text-slate-800", props.className)}>
|
||||
className={cn("scroll-m-20 text-lg font-medium text-slate-800", props.className)}>
|
||||
{props.children}
|
||||
</h3>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user