fix: logo on follow up email (#4837)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
Piyush Gupta
2025-03-08 12:10:40 +05:30
committed by GitHub
parent 432425ea59
commit ddc767e53e
17 changed files with 638 additions and 31 deletions

View File

@@ -0,0 +1,133 @@
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsOrganizationAIReady: vi.fn(),
getWhiteLabelPermission: vi.fn(),
}));
describe("Page", () => {
const mockParams = { environmentId: "test-environment-id" };
const mockSession = { user: { id: "test-user-id" } };
const mockUser = { id: "test-user-id" } as TUser;
const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
const mockMembership = { role: "owner" } as TMembership;
const mockTranslate = vi.fn((key) => key);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
});
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
});
it("renders the page with organization settings", async () => {
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
};
const result = await Page(props);
expect(result).toBeTruthy();
});
it("renders if session user id is null", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } });
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
};
const result = await Page(props);
expect(result).toBeTruthy();
});
it("throws an error if the session is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found");
});
it("throws an error if the organization is not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
"common.organization_not_found"
);
});
});

View File

@@ -12,7 +12,8 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import React from "react";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -84,6 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={params.environmentId}
isReadOnly={!isOwnerOrManager}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
fbLogoUrl={FB_LOGO_URL}
user={user}
/>
{isMultiOrgEnabled && (

View File

@@ -2,6 +2,7 @@
import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react";
import React from "react";
import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({

View File

@@ -0,0 +1,144 @@
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { EmailCustomizationSettings } from "./email-customization-settings";
vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({
removeOrganizationEmailLogoUrlAction: vi.fn(),
sendTestEmailAction: vi.fn(),
updateOrganizationEmailLogoUrlAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/file-input/lib/utils", () => ({
uploadFile: vi.fn(),
}));
const defaultProps = {
organization: {
id: "org-123",
whitelabel: {
logoUrl: "https://example.com/current-logo.png",
},
billing: {
plan: "enterprise",
},
} as TOrganization,
hasWhiteLabelPermission: true,
environmentId: "env-123",
isReadOnly: false,
isFormbricksCloud: false,
user: {
id: "user-123",
name: "Test User",
} as TUser,
fbLogoUrl: "https://example.com/fallback-logo.png",
};
describe("EmailCustomizationSettings", () => {
beforeEach(() => {
cleanup();
});
it("renders the logo if one is set and shows Replace/Remove buttons", () => {
render(<EmailCustomizationSettings {...defaultProps} />);
const logoImage = screen.getByTestId("email-customization-preview-image");
expect(logoImage).toBeInTheDocument();
const srcUrl = new URL(logoImage.getAttribute("src")!, window.location.origin);
const originalUrl = srcUrl.searchParams.get("url");
expect(originalUrl).toBe("https://example.com/current-logo.png");
// Since a logo is present, the “Replace Logo” and “Remove Logo” buttons should appear
expect(screen.getByTestId("replace-logo-button")).toBeInTheDocument();
expect(screen.getByTestId("remove-logo-button")).toBeInTheDocument();
});
it("calls removeOrganizationEmailLogoUrlAction when removing logo", async () => {
vi.mocked(removeOrganizationEmailLogoUrlAction).mockResolvedValue({
data: true,
});
render(<EmailCustomizationSettings {...defaultProps} />);
const user = userEvent.setup();
const removeButton = screen.getByTestId("remove-logo-button");
await user.click(removeButton);
expect(removeOrganizationEmailLogoUrlAction).toHaveBeenCalledTimes(1);
expect(removeOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
organizationId: "org-123",
});
});
it("calls updateOrganizationEmailLogoUrlAction after uploading and clicking save", async () => {
vi.mocked(uploadFile).mockResolvedValueOnce({
uploaded: true,
url: "https://example.com/new-uploaded-logo.png",
});
vi.mocked(updateOrganizationEmailLogoUrlAction).mockResolvedValue({
data: true,
});
render(<EmailCustomizationSettings {...defaultProps} />);
const user = userEvent.setup();
// 1. Replace the logo by uploading a new file
const fileInput = screen.getAllByTestId("upload-file-input");
const testFile = new File(["dummy content"], "test-image.png", { type: "image/png" });
await user.upload(fileInput[0], testFile);
// 2. Click “Save”
const saveButton = screen.getAllByRole("button", { name: /save/i });
await user.click(saveButton[0]);
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
expect(uploadFile).toHaveBeenCalledWith(testFile, ["jpeg", "png", "jpg", "webp"], "env-123");
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
organizationId: "org-123",
logoUrl: "https://example.com/new-uploaded-logo.png",
});
});
it("sends test email if a logo is saved and the user clicks 'Send Test Email'", async () => {
vi.mocked(sendTestEmailAction).mockResolvedValue({
data: { success: true },
});
render(<EmailCustomizationSettings {...defaultProps} />);
const user = userEvent.setup();
const testEmailButton = screen.getByTestId("send-test-email-button");
await user.click(testEmailButton);
expect(sendTestEmailAction).toHaveBeenCalledWith({
organizationId: "org-123",
});
});
it("displays upgrade prompt if hasWhiteLabelPermission is false", () => {
render(<EmailCustomizationSettings {...defaultProps} hasWhiteLabelPermission={false} />);
// Check for text about upgrading
expect(screen.getByText(/customize_email_with_a_higher_plan/i)).toBeInTheDocument();
});
it("shows read-only warning if isReadOnly is true", () => {
render(<EmailCustomizationSettings {...defaultProps} isReadOnly />);
expect(
screen.getByText(/only_owners_managers_and_manage_access_members_can_perform_this_action/i)
).toBeInTheDocument();
});
});

View File

@@ -17,7 +17,7 @@ import { useTranslate } from "@tolgee/react";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TAllowedFileExtension } from "@formbricks/types/common";
@@ -25,8 +25,6 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];
const DEFAULT_LOGO_URL =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
interface EmailCustomizationSettingsProps {
organization: TOrganization;
@@ -35,6 +33,7 @@ interface EmailCustomizationSettingsProps {
isReadOnly: boolean;
isFormbricksCloud: boolean;
user: TUser | null;
fbLogoUrl: string;
}
export const EmailCustomizationSettings = ({
@@ -44,15 +43,16 @@ export const EmailCustomizationSettings = ({
isReadOnly,
isFormbricksCloud,
user,
fbLogoUrl,
}: EmailCustomizationSettingsProps) => {
const { t } = useTranslate();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoUrl, setLogoUrl] = useState<string>(organization.whitelabel?.logoUrl || DEFAULT_LOGO_URL);
const [logoUrl, setLogoUrl] = useState<string>(organization.whitelabel?.logoUrl || fbLogoUrl);
const [isSaving, setIsSaving] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null) as React.RefObject<HTMLInputElement>;
const isDefaultLogo = logoUrl === DEFAULT_LOGO_URL;
const isDefaultLogo = logoUrl === fbLogoUrl;
const router = useRouter();
@@ -202,13 +202,18 @@ export const EmailCustomizationSettings = ({
<div className="flex items-center gap-2">
<Button
data-testid="replace-logo-button"
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={isReadOnly}>
<RepeatIcon className="h-4 w-4" />
{t("environments.settings.general.replace_logo")}
</Button>
<Button onClick={removeLogo} variant="outline" disabled={isReadOnly}>
<Button
data-testid="remove-logo-button"
onClick={removeLogo}
variant="outline"
disabled={isReadOnly}>
<Trash2Icon className="h-4 w-4" />
{t("environments.settings.general.remove_logo")}
</Button>
@@ -233,7 +238,11 @@ export const EmailCustomizationSettings = ({
</div>
<div className="flex gap-4">
<Button variant="secondary" disabled={isReadOnly} onClick={sendTestEmail}>
<Button
data-testid="send-test-email-button"
variant="secondary"
disabled={isReadOnly}
onClick={sendTestEmail}>
{t("common.send_test_email")}
</Button>
<Button onClick={handleSave} disabled={!logoFile || isReadOnly} loading={isSaving}>
@@ -243,7 +252,8 @@ export const EmailCustomizationSettings = ({
</div>
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10">
<Image
src={logoUrl || DEFAULT_LOGO_URL}
data-testid="email-customization-preview-image"
src={logoUrl || fbLogoUrl}
alt="Logo"
className="mx-auto max-h-[100px] max-w-full object-contain"
width={192}

View File

@@ -0,0 +1,85 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { TFnType } from "@tolgee/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EmailTemplate } from "./email-template";
const mockTranslate: TFnType = (key) => key;
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
IMPRINT_URL: "https://example.com/imprint",
PRIVACY_URL: "https://example.com/privacy",
IMPRINT_ADDRESS: "Imprint Address",
}));
const defaultProps = {
children: <div data-testid="child-text">Test Content</div>,
logoUrl: "https://example.com/custom-logo.png",
t: mockTranslate,
};
describe("EmailTemplate", () => {
beforeEach(() => {
cleanup();
});
it("renders the default logo if no custom logo is provided", async () => {
const emailTemplateElement = await EmailTemplate({
children: <div>Test Content</div>,
logoUrl: undefined,
t: mockTranslate,
});
render(emailTemplateElement);
const logoImage = screen.getByTestId("default-logo-image");
expect(logoImage).toBeInTheDocument();
expect(logoImage).toHaveAttribute("src", "https://example.com/mock-logo.png");
});
it("renders the custom logo if provided", async () => {
const emailTemplateElement = await EmailTemplate({
...defaultProps,
});
render(emailTemplateElement);
const logoImage = screen.getByTestId("logo-image");
expect(logoImage).toBeInTheDocument();
expect(logoImage).toHaveAttribute("src", "https://example.com/custom-logo.png");
});
it("renders the children content", async () => {
const emailTemplateElement = await EmailTemplate({
...defaultProps,
});
render(emailTemplateElement);
expect(screen.getByTestId("child-text")).toBeInTheDocument();
});
it("renders the imprint and privacy policy links if provided", async () => {
const emailTemplateElement = await EmailTemplate({
...defaultProps,
});
render(emailTemplateElement);
expect(screen.getByText("emails.imprint")).toBeInTheDocument();
expect(screen.getByText("emails.privacy_policy")).toBeInTheDocument();
});
it("renders the imprint address if provided", async () => {
const emailTemplateElement = await EmailTemplate({
...defaultProps,
});
render(emailTemplateElement);
expect(screen.getByText("emails.email_template_text_1")).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,15 @@
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { TFnType } from "@tolgee/react";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
import React from "react";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
const fbLogoUrl =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface EmailTemplateProps {
children: React.ReactNode;
logoUrl?: string;
t: TFnType;
readonly children: React.ReactNode;
readonly logoUrl?: string;
readonly t: TFnType;
}
export async function EmailTemplate({
@@ -30,10 +30,15 @@ export async function EmailTemplate({
<Section>
{isDefaultLogo ? (
<Link href={logoLink} target="_blank">
<Img alt="Logo" className="mx-auto w-80" src={fbLogoUrl} />
<Img data-testid="default-logo-image" alt="Logo" className="mx-auto w-80" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-80 object-contain" src={logoUrl} />
<Img
data-testid="logo-image"
alt="Logo"
className="mx-auto max-h-[100px] w-80 object-contain"
src={logoUrl}
/>
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left">

View File

@@ -0,0 +1,88 @@
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FollowUpEmail } from "./follow-up";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
IMPRINT_URL: "https://example.com/imprint",
PRIVACY_URL: "https://example.com/privacy",
IMPRINT_ADDRESS: "Imprint Address",
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
const defaultProps = {
html: "<p>Test HTML Content</p>",
logoUrl: "https://example.com/custom-logo.png",
};
describe("FollowUpEmail", () => {
beforeEach(() => {
vi.mocked(getTranslate).mockResolvedValue(
((key: string) => key) as TFnType<DefaultParamType, string, TranslationKey>
);
});
it("renders the default logo if no custom logo is provided", async () => {
const followUpEmailElement = await FollowUpEmail({
...defaultProps,
logoUrl: undefined,
});
render(followUpEmailElement);
const logoImage = screen.getByAltText("Logo");
expect(logoImage).toBeInTheDocument();
expect(logoImage).toHaveAttribute("src", "https://example.com/mock-logo.png");
});
it("renders the custom logo if provided", async () => {
const followUpEmailElement = await FollowUpEmail({
...defaultProps,
});
render(followUpEmailElement);
const logoImage = screen.getByAltText("Logo");
expect(logoImage).toBeInTheDocument();
expect(logoImage).toHaveAttribute("src", "https://example.com/custom-logo.png");
});
it("renders the HTML content", async () => {
const followUpEmailElement = await FollowUpEmail({
...defaultProps,
});
render(followUpEmailElement);
expect(screen.getByText("Test HTML Content")).toBeInTheDocument();
});
it("renders the imprint and privacy policy links if provided", async () => {
const followUpEmailElement = await FollowUpEmail({
...defaultProps,
});
render(followUpEmailElement);
expect(screen.getByText("emails.imprint")).toBeInTheDocument();
expect(screen.getByText("emails.privacy_policy")).toBeInTheDocument();
});
it("renders the imprint address if provided", async () => {
const followUpEmailElement = await FollowUpEmail({
...defaultProps,
});
render(followUpEmailElement);
expect(screen.getByText("emails.powered_by_formbricks")).toBeInTheDocument();
expect(screen.getByText("Imprint Address")).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,22 @@
import { getTranslate } from "@/tolgee/server";
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
import React from "react";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface FollowUpEmailProps {
html: string;
logoUrl?: string;
readonly html: string;
readonly logoUrl?: string;
}
export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
console.log(t("emails.imprint"));
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
<Html>
<Tailwind>
@@ -18,11 +25,15 @@ export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Prom
style={{
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
{logoUrl && (
<Section>
<Section>
{isDefaultLogo ? (
<Link href={logoLink} target="_blank">
<Img alt="Logo" className="mx-auto w-80" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-80 object-contain" src={logoUrl} />
</Section>
)}
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left">
<div
dangerouslySetInnerHTML={{

View File

@@ -1,4 +1,5 @@
import { ArrowUpFromLineIcon } from "lucide-react";
import React from "react";
import { cn } from "@formbricks/lib/cn";
import { TAllowedFileExtension } from "@formbricks/types/common";
@@ -47,6 +48,7 @@ export const Uploader = ({
<span className="font-semibold">Click or drag to upload files.</span>
</p>
<input
data-testid="upload-file-input"
type="file"
id={`${id}-${name}`}
name={`${id}-${name}`}

View File

@@ -1,6 +1,7 @@
import { Button } from "@/modules/ui/components/button";
import { KeyIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
export type ModalButton = {
text: string;

View File

@@ -67,6 +67,7 @@
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/typography": "0.5.15",
"@tanstack/react-table": "8.20.6",
"@testing-library/jest-dom": "6.6.3",
"@tolgee/cli": "2.8.1",
"@tolgee/format-icu": "6.0.1",
"@tolgee/react": "6.0.1",
@@ -136,6 +137,7 @@
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@neshca/cache-handler": "1.9.0",
"@testing-library/react": "16.2.0",
"@types/bcryptjs": "2.4.6",
"@types/heic-convert": "2.1.0",
"@types/lodash": "4.17.13",
@@ -143,6 +145,7 @@
"@types/nodemailer": "6.4.17",
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"@types/testing-library__react": "10.2.0",
"@vitest/coverage-v8": "2.1.8",
"vite": "6.2.0",
"vite-tsconfig-paths": "5.1.4",

View File

@@ -5,6 +5,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: "node",
environmentMatchGlobs: [
["**/page.test.tsx", "node"], // page files use node environment because it uses server-side rendering
["**/*.test.tsx", "jsdom"],
],
exclude: ['playwright/**', 'node_modules/**'],
setupFiles: ['../../packages/lib/vitestSetup.ts'],
env: loadEnv('', process.cwd(), ''),
@@ -16,6 +21,10 @@ export default defineConfig({
'modules/api/v2/**/*.ts',
'modules/auth/lib/**/*.ts',
'modules/signup/lib/**/*.ts',
'modules/ee/whitelabel/email-customization/components/*.tsx',
'modules/email/components/email-template.tsx',
'modules/email/emails/survey/follow-up.tsx',
'app/(app)/environments/**/settings/(organization)/general/page.tsx',
],
exclude: [
'**/.next/**',

View File

@@ -15,6 +15,8 @@ export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
// Other
export const CRON_SECRET = env.CRON_SECRET;
export const DEFAULT_BRAND_COLOR = "#64748b";
export const FB_LOGO_URL =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
export const PRIVACY_URL = env.PRIVACY_URL;
export const TERMS_URL = env.TERMS_URL;
@@ -254,6 +256,8 @@ export const BILLING_LIMITS = {
},
} as const;
export const AI_AZURE_LLM_RESSOURCE_NAME = env.AI_AZURE_LLM_RESSOURCE_NAME;
export const IS_AI_CONFIGURED = Boolean(
env.AI_AZURE_EMBEDDINGS_API_KEY &&
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&

View File

@@ -1,4 +1,5 @@
// mock these globally used functions
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ValidationError } from "@formbricks/types/errors";
@@ -13,19 +14,67 @@ vi.mock("next/cache", () => ({
// mock react cache
const testCache = <T extends Function>(func: T) => func;
vi.mock("react", () => {
const originalModule = vi.importActual("react");
vi.mock("react", async () => {
const react = await vi.importActual<typeof import("react")>("react");
return {
...originalModule,
...react,
cache: testCache,
};
});
vi.mock("@tolgee/react", () => ({
useTranslate: () => {
return {
t: (key: string) => key,
};
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
}));
// mock server-only
vi.mock("server-only", () => {
return {};
});
vi.mock("@prisma/client", async () => {
const actual = await vi.importActual<typeof import("@prisma/client")>("@prisma/client");
return {
...actual,
Prisma: actual.Prisma,
PrismaClient: class {
$connect() {
return Promise.resolve();
}
$disconnect() {
return Promise.resolve();
}
$extends() {
return this;
}
},
};
});
if (typeof URL.revokeObjectURL !== "function") {
URL.revokeObjectURL = () => {};
}
if (typeof URL.createObjectURL !== "function") {
URL.createObjectURL = () => "blob://fake-url";
}
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();

62
pnpm-lock.yaml generated
View File

@@ -330,6 +330,9 @@ importers:
'@tanstack/react-table':
specifier: 8.20.6
version: 8.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@testing-library/jest-dom':
specifier: 6.6.3
version: 6.6.3
'@tolgee/cli':
specifier: 2.8.1
version: 2.8.1(jiti@2.4.1)(typescript@5.7.2)
@@ -532,6 +535,9 @@ importers:
'@neshca/cache-handler':
specifier: 1.9.0
version: 1.9.0(next@15.1.2(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)
'@testing-library/react':
specifier: 16.2.0
version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@types/bcryptjs':
specifier: 2.4.6
version: 2.4.6
@@ -553,6 +559,9 @@ importers:
'@types/qrcode':
specifier: 1.5.5
version: 1.5.5
'@types/testing-library__react':
specifier: 10.2.0
version: 10.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@vitest/coverage-v8':
specifier: 2.1.8
version: 2.1.8(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))
@@ -5575,6 +5584,25 @@ packages:
resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/jest-dom@6.6.3':
resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/react@16.2.0':
resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@testing-library/user-event@14.5.2':
resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==}
engines: {node: '>=12', npm: '>=6'}
@@ -5816,6 +5844,10 @@ packages:
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
'@types/testing-library__react@10.2.0':
resolution: {integrity: sha512-KbU7qVfEwml8G5KFxM+xEfentAAVj/SOQSjW0+HqzjPE0cXpt0IpSamfX4jGYCImznDHgQcfXBPajS7HjLZduw==}
deprecated: This is a stub types definition. testing-library__react provides its own type definitions, so you do not need this installed.
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -20003,6 +20035,26 @@ snapshots:
lodash: 4.17.21
redent: 3.0.0
'@testing-library/jest-dom@6.6.3':
dependencies:
'@adobe/css-tools': 4.4.1
aria-query: 5.3.2
chalk: 3.0.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
lodash: 4.17.21
redent: 3.0.0
'@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@babel/runtime': 7.26.7
'@testing-library/dom': 10.4.0
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.1
'@types/react-dom': 19.0.2(@types/react@19.0.1)
'@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)':
dependencies:
'@testing-library/dom': 10.4.0
@@ -20263,6 +20315,16 @@ snapshots:
dependencies:
'@types/node': 22.10.2
'@types/testing-library__react@10.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@testing-library/react': 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
transitivePeerDependencies:
- '@testing-library/dom'
- '@types/react'
- '@types/react-dom'
- react
- react-dom
'@types/trusted-types@2.0.7':
optional: true

View File

@@ -9,8 +9,6 @@ sonar.exclusions=**/node_modules/**,**/.next/**,**/dist/**,**/build/**,**/*.test
sonar.tests=apps/web
sonar.test.inclusions=**/*.test.*,**/*.spec.*
sonar.javascript.lcov.reportPaths=apps/web/coverage/lcov.info
sonar.coverage.exclusions=**/constants.ts,**/route.ts,**/openapi.ts,**/openapi-document.ts,modules/**/types/**,playwright/**,**/*.test.*,**/*.spec.*
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,**/openapi.ts,**/openapi-document.ts,modules/**/types/**
# TypeScript configuration
sonar.typescript.tsconfigPath=apps/web/tsconfig.json
@@ -23,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts