mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 16:16:21 -06:00
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:
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
85
apps/web/modules/email/components/email-template.test.tsx
Normal file
85
apps/web/modules/email/components/email-template.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
88
apps/web/modules/email/emails/survey/follow-up.test.tsx
Normal file
88
apps/web/modules/email/emails/survey/follow-up.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/**',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
62
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user