-
-
- {!csvResponse.length ? (
-
-
-
- ) : (
-
-
- {t("environments.contacts.upload_contacts_modal_preview")}
-
-
-
-
-
- )}
-
- {!csvResponse.length && (
-
-
-
- )}
-
-
- {csvResponse.length > 0 ? (
-
-
- {t("environments.contacts.upload_contacts_modal_attributes_title")}
-
-
- {t("environments.contacts.upload_contacts_modal_attributes_description")}
-
+
-
-
-
+
{csvResponse.length > 0 ? (
) : null}
-
-
-
+
+
+
>
);
};
diff --git a/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts b/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts
index e84f6b34a3..bbba2228ce 100644
--- a/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts
+++ b/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts
@@ -10,6 +10,12 @@ vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
+ TokenExpiredError: class TokenExpiredError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "TokenExpiredError";
+ }
+ },
},
}));
@@ -145,8 +151,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
- message: "Invalid or expired survey token",
- details: [{ field: "token", issue: "Invalid or expired survey token" }],
+ message: "Invalid survey token",
+ details: [{ field: "token", issue: "invalid_token" }],
});
}
});
@@ -166,8 +172,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
- message: "Invalid or expired survey token",
- details: [{ field: "token", issue: "Invalid or expired survey token" }],
+ message: "Invalid survey token",
+ details: [{ field: "token", issue: "invalid_token" }],
});
}
});
diff --git a/apps/web/modules/ee/contacts/lib/contact-survey-link.ts b/apps/web/modules/ee/contacts/lib/contact-survey-link.ts
index 0caf191b11..30cd4204df 100644
--- a/apps/web/modules/ee/contacts/lib/contact-survey-link.ts
+++ b/apps/web/modules/ee/contacts/lib/contact-survey-link.ts
@@ -3,6 +3,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
+import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
@@ -73,11 +74,22 @@ export const verifyContactSurveyToken = (
surveyId,
});
} catch (error) {
- console.error("Error verifying contact survey token:", error);
+ logger.error("Error verifying contact survey token:", error);
+
+ // Check if the error is specifically a JWT expiration error
+ if (error instanceof jwt.TokenExpiredError) {
+ return err({
+ type: "bad_request",
+ message: "Survey link has expired",
+ details: [{ field: "token", issue: "token_expired" }],
+ });
+ }
+
+ // Handle other JWT errors or general validation errors
return err({
type: "bad_request",
- message: "Invalid or expired survey token",
- details: [{ field: "token", issue: "Invalid or expired survey token" }],
+ message: "Invalid survey token",
+ details: [{ field: "token", issue: "invalid_token" }],
});
}
};
diff --git a/apps/web/modules/ee/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/lib/contacts.test.ts
index d4796ee099..b32374a844 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.test.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.test.ts
@@ -6,10 +6,31 @@ import {
buildContactWhereClause,
createContactsFromCSV,
deleteContact,
+ generatePersonalLinks,
getContact,
getContacts,
+ getContactsInSegment,
} from "./contacts";
+// Mock additional dependencies for the new functions
+vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
+ getSegment: vi.fn(),
+}));
+
+vi.mock("@/modules/ee/contacts/segments/lib/filter/prisma-query", () => ({
+ segmentFilterToPrismaQuery: vi.fn(),
+}));
+
+vi.mock("@/modules/ee/contacts/lib/contact-survey-link", () => ({
+ getContactSurveyLink: vi.fn(),
+}));
+
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ error: vi.fn(),
+ },
+}));
+
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
@@ -31,11 +52,18 @@ vi.mock("@formbricks/database", () => ({
},
},
}));
-vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
+vi.mock("@/lib/constants", () => ({
+ ITEMS_PER_PAGE: 2,
+ ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
+ IS_PRODUCTION: false,
+ IS_POSTHOG_CONFIGURED: false,
+ POSTHOG_API_HOST: "test-posthog-host",
+ POSTHOG_API_KEY: "test-posthog-key",
+}));
-const environmentId = "env1";
-const contactId = "contact1";
-const userId = "user1";
+const environmentId = "cm123456789012345678901237";
+const contactId = "cm123456789012345678901238";
+const userId = "cm123456789012345678901239";
const mockContact: Contact & {
attributes: { value: string; attributeKey: { key: string; name: string } }[];
} = {
@@ -159,7 +187,7 @@ describe("createContactsFromCSV", () => {
.mockResolvedValueOnce([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
- ]);
+ ] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "c1",
@@ -183,12 +211,12 @@ describe("createContactsFromCSV", () => {
test("skips duplicate contact with 'skip' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
- ]);
+ ] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
- ]);
+ ] as any);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email",
@@ -206,12 +234,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" },
],
},
- ]);
+ ] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
- ]);
+ ] as any);
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
environmentId,
@@ -239,12 +267,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" },
],
},
- ]);
+ ] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
- ]);
+ ] as any);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
@@ -326,8 +354,333 @@ describe("buildContactWhereClause", () => {
});
test("returns where clause without search", () => {
- const environmentId = "env-1";
+ const environmentId = "cm123456789012345678901240";
const result = buildContactWhereClause(environmentId);
expect(result).toEqual({ environmentId });
});
});
+
+describe("getContactsInSegment", () => {
+ const mockSegmentId = "cm123456789012345678901235";
+ const mockEnvironmentId = "cm123456789012345678901236";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("returns contacts when segment and filters are valid", async () => {
+ const mockSegment = {
+ id: mockSegmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: mockEnvironmentId,
+ description: "Test segment",
+ title: "Test Segment",
+ isPrivate: false,
+ surveys: [],
+ filters: [],
+ };
+
+ const mockContacts = [
+ {
+ id: "contact-1",
+ attributes: [
+ { attributeKey: { key: "email" }, value: "test@example.com" },
+ { attributeKey: { key: "name" }, value: "Test User" },
+ ],
+ },
+ {
+ id: "contact-2",
+ attributes: [
+ { attributeKey: { key: "email" }, value: "another@example.com" },
+ { attributeKey: { key: "name" }, value: "Another User" },
+ ],
+ },
+ ] as any;
+
+ const mockWhereClause = {
+ environmentId: mockEnvironmentId,
+ attributes: {
+ some: {
+ attributeKey: { key: "email" },
+ value: "test@example.com",
+ },
+ },
+ };
+
+ // Mock the dependencies
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+ const { segmentFilterToPrismaQuery } = await import(
+ "@/modules/ee/contacts/segments/lib/filter/prisma-query"
+ );
+
+ vi.mocked(getSegment).mockResolvedValue(mockSegment);
+
+ vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
+ ok: true,
+ data: { whereClause: mockWhereClause },
+ } as any);
+
+ vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
+
+ const result = await getContactsInSegment(mockSegmentId);
+
+ expect(result).toEqual([
+ {
+ contactId: "contact-1",
+ attributes: {
+ email: "test@example.com",
+ name: "Test User",
+ },
+ },
+ {
+ contactId: "contact-2",
+ attributes: {
+ email: "another@example.com",
+ name: "Another User",
+ },
+ },
+ ]);
+
+ expect(prisma.contact.findMany).toHaveBeenCalledWith({
+ where: mockWhereClause,
+ select: {
+ id: true,
+ attributes: {
+ where: {
+ attributeKey: {
+ key: {
+ in: ["userId", "firstName", "lastName", "email"],
+ },
+ },
+ },
+ select: {
+ attributeKey: {
+ select: {
+ key: true,
+ },
+ },
+ value: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+ });
+
+ test("returns null when segment is not found", async () => {
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+
+ vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
+
+ const result = await getContactsInSegment(mockSegmentId);
+
+ expect(result).toBeNull();
+ });
+
+ test("returns null when segment filter to prisma query fails", async () => {
+ const mockSegment = {
+ id: mockSegmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: mockEnvironmentId,
+ description: "Test segment",
+ title: "Test Segment",
+ isPrivate: false,
+ surveys: [],
+ filters: [],
+ };
+
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+ const { segmentFilterToPrismaQuery } = await import(
+ "@/modules/ee/contacts/segments/lib/filter/prisma-query"
+ );
+
+ vi.mocked(getSegment).mockResolvedValue(mockSegment);
+
+ vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
+ ok: false,
+ error: { type: "bad_request" },
+ } as any);
+
+ const result = await getContactsInSegment(mockSegmentId);
+
+ expect(result).toBeNull();
+ });
+
+ test("returns null when prisma query fails", async () => {
+ const mockSegment = {
+ id: mockSegmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: mockEnvironmentId,
+ description: "Test segment",
+ title: "Test Segment",
+ isPrivate: false,
+ surveys: [],
+ filters: [],
+ };
+
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+ const { segmentFilterToPrismaQuery } = await import(
+ "@/modules/ee/contacts/segments/lib/filter/prisma-query"
+ );
+
+ vi.mocked(getSegment).mockResolvedValue(mockSegment);
+
+ vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
+ ok: true,
+ data: { whereClause: {} },
+ } as any);
+
+ vi.mocked(prisma.contact.findMany).mockRejectedValue(new Error("Database error"));
+
+ const result = await getContactsInSegment(mockSegmentId);
+
+ expect(result).toBeNull();
+ });
+
+ test("handles errors gracefully", async () => {
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+
+ vi.mocked(getSegment).mockRejectedValue(new Error("Database error"));
+
+ const result = await getContactsInSegment(mockSegmentId);
+
+ expect(result).toBeNull(); // The function catches errors and returns null
+ });
+});
+
+describe("generatePersonalLinks", () => {
+ const mockSurveyId = "cm123456789012345678901234"; // Valid CUID2 format
+ const mockSegmentId = "cm123456789012345678901235"; // Valid CUID2 format
+ const mockExpirationDays = 7;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("returns null when getContactsInSegment fails", async () => {
+ // Mock getSegment to fail which will cause getContactsInSegment to return null
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+
+ vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
+
+ const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
+
+ expect(result).toBeNull();
+ });
+
+ test("returns empty array when no contacts in segment", async () => {
+ // Mock successful segment retrieval but no contacts
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+ const { segmentFilterToPrismaQuery } = await import(
+ "@/modules/ee/contacts/segments/lib/filter/prisma-query"
+ );
+
+ vi.mocked(getSegment).mockResolvedValue({
+ id: mockSegmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: "env-123",
+ description: "Test segment",
+ title: "Test Segment",
+ isPrivate: false,
+ surveys: [],
+ filters: [],
+ });
+
+ vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
+ ok: true,
+ data: { whereClause: {} },
+ } as any);
+
+ vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
+
+ const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
+
+ expect(result).toEqual([]);
+ });
+
+ test("generates personal links for contacts successfully", async () => {
+ // Mock all the dependencies that getContactsInSegment needs
+ const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
+ const { segmentFilterToPrismaQuery } = await import(
+ "@/modules/ee/contacts/segments/lib/filter/prisma-query"
+ );
+ const { getContactSurveyLink } = await import("@/modules/ee/contacts/lib/contact-survey-link");
+
+ vi.mocked(getSegment).mockResolvedValue({
+ id: mockSegmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: "env-123",
+ description: "Test segment",
+ title: "Test Segment",
+ isPrivate: false,
+ surveys: [],
+ filters: [],
+ });
+
+ vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
+ ok: true,
+ data: { whereClause: {} },
+ } as any);
+
+ vi.mocked(prisma.contact.findMany).mockResolvedValue([
+ {
+ id: "contact-1",
+ attributes: [
+ { attributeKey: { key: "email" }, value: "test@example.com" },
+ { attributeKey: { key: "name" }, value: "Test User" },
+ ],
+ },
+ {
+ id: "contact-2",
+ attributes: [
+ { attributeKey: { key: "email" }, value: "another@example.com" },
+ { attributeKey: { key: "name" }, value: "Another User" },
+ ],
+ },
+ ] as any);
+
+ // Mock getContactSurveyLink to return successful results
+ vi.mocked(getContactSurveyLink)
+ .mockReturnValueOnce({
+ ok: true,
+ data: "https://example.com/survey/link1",
+ })
+ .mockReturnValueOnce({
+ ok: true,
+ data: "https://example.com/survey/link2",
+ });
+
+ const result = await generatePersonalLinks(mockSurveyId, mockSegmentId, mockExpirationDays);
+
+ expect(result).toEqual([
+ {
+ contactId: "contact-1",
+ attributes: {
+ email: "test@example.com",
+ name: "Test User",
+ },
+ surveyUrl: "https://example.com/survey/link1",
+ expirationDays: mockExpirationDays,
+ },
+ {
+ contactId: "contact-2",
+ attributes: {
+ email: "another@example.com",
+ name: "Another User",
+ },
+ surveyUrl: "https://example.com/survey/link2",
+ expirationDays: mockExpirationDays,
+ },
+ ]);
+
+ expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", mockSurveyId, mockExpirationDays);
+ expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", mockSurveyId, mockExpirationDays);
+ });
+});
diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts
index 922c2f39b5..499a44b151 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.ts
@@ -1,9 +1,13 @@
import "server-only";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
+import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
+import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
+import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
+import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
@@ -15,6 +19,76 @@ import {
} from "../types/contact";
import { transformPrismaContact } from "./utils";
+export const getContactsInSegment = reactCache(async (segmentId: string) => {
+ try {
+ const segment = await getSegment(segmentId);
+
+ if (!segment) {
+ return null;
+ }
+
+ const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
+ segment.id,
+ segment.filters,
+ segment.environmentId
+ );
+
+ if (!segmentFilterToPrismaQueryResult.ok) {
+ return null;
+ }
+
+ const { whereClause } = segmentFilterToPrismaQueryResult.data;
+
+ const requiredAttributes = ["userId", "firstName", "lastName", "email"];
+
+ const contacts = await prisma.contact.findMany({
+ where: whereClause,
+ select: {
+ id: true,
+ attributes: {
+ where: {
+ attributeKey: {
+ key: {
+ in: requiredAttributes,
+ },
+ },
+ },
+ select: {
+ attributeKey: {
+ select: {
+ key: true,
+ },
+ },
+ value: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ const contactsWithAttributes = contacts.map((contact) => {
+ const attributes = contact.attributes.reduce(
+ (acc, attr) => {
+ acc[attr.attributeKey.key] = attr.value;
+ return acc;
+ },
+ {} as Record
+ );
+ return {
+ contactId: contact.id,
+ attributes,
+ };
+ });
+
+ return contactsWithAttributes;
+ } catch (error) {
+ logger.error(error, "Failed to get contacts in segment");
+ return null;
+ }
+});
+
const selectContact = {
id: true,
createdAt: true,
@@ -418,3 +492,37 @@ export const createContactsFromCSV = async (
throw error;
}
};
+
+export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays?: number) => {
+ const contactsResult = await getContactsInSegment(segmentId);
+
+ if (!contactsResult) {
+ return null;
+ }
+
+ // Generate survey links for each contact
+ const contactLinks = contactsResult
+ .map((contact) => {
+ const { contactId, attributes } = contact;
+
+ const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
+
+ if (!surveyUrlResult.ok) {
+ logger.error(
+ { error: surveyUrlResult.error, contactId: contactId, surveyId: surveyId },
+ "Failed to generate survey URL for contact"
+ );
+ return null;
+ }
+
+ return {
+ contactId,
+ attributes,
+ surveyUrl: surveyUrlResult.data,
+ expirationDays,
+ };
+ })
+ .filter(Boolean);
+
+ return contactLinks;
+};
diff --git a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.test.tsx b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.test.tsx
index 9a7ca51583..65158153ce 100644
--- a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.test.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.test.tsx
@@ -6,47 +6,66 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
-// Mock the Modal component
-vi.mock("@/modules/ui/components/modal", () => ({
- Modal: ({
+// Mock the Dialog components
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({
children,
open,
- closeOnOutsideClick,
- setOpen,
+ onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
- closeOnOutsideClick?: boolean;
- setOpen?: (open: boolean) => void;
- }) => {
- return open ? ( // NOSONAR // This is a mock
- {
- if (closeOnOutsideClick && e.target === e.currentTarget && setOpen) {
- setOpen(false);
- }
- }}>
- {children}
-
- ) : null; // NOSONAR // This is a mock
- },
+ onOpenChange: (open: boolean) => void;
+ }) =>
+ open ? (
+
+ {children}
+ onOpenChange(false)}>
+ Close
+
+
+ ) : null,
+ DialogContent: ({
+ children,
+ className,
+ hideCloseButton,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ hideCloseButton?: boolean;
+ }) => (
+
+ {children}
+
+ ),
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogBody: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+// Mock the Input component
+vi.mock("@/modules/ui/components/input", () => ({
+ Input: ({ placeholder, onChange, autoFocus }: any) => (
+
+ ),
}));
// Mock the TabBar component
vi.mock("@/modules/ui/components/tab-bar", () => ({
- TabBar: ({
- tabs,
- activeId,
- setActiveId,
- }: {
- tabs: any[];
- activeId: string;
- setActiveId: (id: string) => void;
- }) => (
-
- {tabs.map((tab) => (
-
setActiveId(tab.id)}>
+ TabBar: ({ tabs, activeId, setActiveId }: any) => (
+
+ {tabs.map((tab: any) => (
+
setActiveId(tab.id)}
+ className={activeId === tab.id ? "active" : ""}>
{tab.label} {activeId === tab.id ? "(Active)" : ""}
))}
@@ -54,11 +73,94 @@ vi.mock("@/modules/ui/components/tab-bar", () => ({
),
}));
+// Mock the useTranslate hook
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({
+ t: (key: string) => {
+ const translations = {
+ "common.add_filter": "Add Filter",
+ "common.all": "All",
+ "environments.segments.person_and_attributes": "Person & Attributes",
+ "common.segments": "Segments",
+ "environments.segments.devices": "Devices",
+ "environments.segments.phone": "Phone",
+ "environments.segments.desktop": "Desktop",
+ "environments.segments.no_filters_yet": "No filters yet",
+ "environments.segments.no_segments_yet": "No segments yet",
+ "environments.segments.no_attributes_yet": "No attributes yet",
+ "common.user_id": "userId",
+ "common.person": "Person",
+ "common.attributes": "Attributes",
+ };
+ return translations[key] || key;
+ },
+ }),
+}));
+
// Mock createId
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mockCuid"),
}));
+// Mock the AttributeTabContent component
+vi.mock("./attribute-tab-content", () => ({
+ default: ({ contactAttributeKeys, onAddFilter, setOpen, handleAddFilter }: any) => (
+
+
Person
+
handleAddFilter({ type: "person", onAddFilter, setOpen })}
+ onKeyDown={(e: any) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleAddFilter({ type: "person", onAddFilter, setOpen });
+ }
+ }}
+ tabIndex={0}>
+ userId
+
+
+
Attributes
+ {contactAttributeKeys.length === 0 ? (
+
No attributes yet
+ ) : (
+ contactAttributeKeys.map((attr: any) => (
+
+ handleAddFilter({ type: "attribute", onAddFilter, setOpen, contactAttributeKey: attr.key })
+ }
+ onKeyDown={(e: any) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleAddFilter({ type: "attribute", onAddFilter, setOpen, contactAttributeKey: attr.key });
+ }
+ }}
+ tabIndex={0}>
+ {attr.name ?? attr.key}
+
+ ))
+ )}
+
+ ),
+}));
+
+// Mock the FilterButton component
+vi.mock("./filter-button", () => ({
+ default: ({ icon, label, onClick, onKeyDown, tabIndex = 0, ...props }: any) => (
+
+ {icon}
+ {label}
+
+ ),
+}));
+
const mockContactAttributeKeys: TContactAttributeKey[] = [
{
id: "attr1",
@@ -154,16 +256,20 @@ describe("AddFilterModal", () => {
/>
);
// ... assertions ...
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-title")).toHaveTextContent("Add Filter");
+ expect(screen.getByTestId("search-input")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Browse filters...")).toBeInTheDocument();
- expect(screen.getByTestId("tab-all")).toHaveTextContent("common.all (Active)");
+ expect(screen.getByTestId("tab-all")).toHaveTextContent("All (Active)");
expect(screen.getByText("Email Address")).toBeInTheDocument();
expect(screen.getByText("Plan Type")).toBeInTheDocument();
expect(screen.getByText("userId")).toBeInTheDocument();
expect(screen.getByText("Active Users")).toBeInTheDocument();
expect(screen.getByText("Paying Customers")).toBeInTheDocument();
expect(screen.queryByText("Private Segment")).not.toBeInTheDocument();
- expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
- expect(screen.getByText("environments.segments.desktop")).toBeInTheDocument();
+ expect(screen.getByText("Phone")).toBeInTheDocument();
+ expect(screen.getByText("Desktop")).toBeInTheDocument();
});
test("does not render when closed", () => {
@@ -176,6 +282,7 @@ describe("AddFilterModal", () => {
segments={mockSegments}
/>
);
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Browse filters...")).not.toBeInTheDocument();
});
@@ -210,22 +317,22 @@ describe("AddFilterModal", () => {
const attributesTabButton = screen.getByTestId("tab-attributes");
await user.click(attributesTabButton);
// ... assertions ...
- expect(attributesTabButton).toHaveTextContent("environments.segments.person_and_attributes (Active)");
- expect(screen.getByText("common.user_id")).toBeInTheDocument();
+ expect(attributesTabButton).toHaveTextContent("Person & Attributes (Active)");
+ expect(screen.getByText("userId")).toBeInTheDocument();
// Switch to Segments tab
const segmentsTabButton = screen.getByTestId("tab-segments");
await user.click(segmentsTabButton);
// ... assertions ...
- expect(segmentsTabButton).toHaveTextContent("common.segments (Active)");
+ expect(segmentsTabButton).toHaveTextContent("Segments (Active)");
expect(screen.getByText("Active Users")).toBeInTheDocument();
// Switch to Devices tab
const devicesTabButton = screen.getByTestId("tab-devices");
await user.click(devicesTabButton);
// ... assertions ...
- expect(devicesTabButton).toHaveTextContent("environments.segments.devices (Active)");
- expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
+ expect(devicesTabButton).toHaveTextContent("Devices (Active)");
+ expect(screen.getByText("Phone")).toBeInTheDocument();
});
// --- Click and Keydown Tests ---
@@ -499,7 +606,7 @@ describe("AddFilterModal", () => {
/>
);
await user.click(screen.getByTestId("tab-attributes"));
- expect(await screen.findByText("environments.segments.no_attributes_yet")).toBeInTheDocument();
+ expect(await screen.findByText("No attributes yet")).toBeInTheDocument();
});
test("displays 'no segments yet' message", async () => {
@@ -513,7 +620,7 @@ describe("AddFilterModal", () => {
/>
);
await user.click(screen.getByTestId("tab-segments"));
- expect(await screen.findByText("environments.segments.no_segments_yet")).toBeInTheDocument();
+ expect(await screen.findByText("No segments yet")).toBeInTheDocument();
});
test("displays 'no filters match' message when search yields no results", async () => {
@@ -528,7 +635,7 @@ describe("AddFilterModal", () => {
);
const searchInput = screen.getByPlaceholderText("Browse filters...");
await user.type(searchInput, "nonexistentfilter");
- expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument();
+ expect(await screen.findByText("No filters yet")).toBeInTheDocument();
});
test("verifies keyboard navigation through filter buttons", async () => {
@@ -548,19 +655,19 @@ describe("AddFilterModal", () => {
// Tab to the first tab button ("all")
await user.tab();
- expect(document.activeElement).toHaveTextContent(/common\.all/);
+ expect(document.activeElement).toHaveTextContent(/All/);
// Tab to the second tab button ("attributes")
await user.tab();
- expect(document.activeElement).toHaveTextContent(/person_and_attributes/);
+ expect(document.activeElement).toHaveTextContent(/Person & Attributes/);
// Tab to the third tab button ("segments")
await user.tab();
- expect(document.activeElement).toHaveTextContent(/common\.segments/);
+ expect(document.activeElement).toHaveTextContent(/Segments/);
// Tab to the fourth tab button ("devices")
await user.tab();
- expect(document.activeElement).toHaveTextContent(/environments\.segments\.devices/);
+ expect(document.activeElement).toHaveTextContent(/Devices/);
// Tab to the first filter button ("Email Address")
await user.tab();
@@ -595,21 +702,4 @@ describe("AddFilterModal", () => {
expect(button).not.toHaveAttribute("tabIndex", "-1"); // Should not be unfocusable
});
});
-
- test("closes the modal when clicking outside the content area", async () => {
- render(
-
- );
-
- const modalOverlay = screen.getByTestId("modal-overlay");
- await user.click(modalOverlay);
-
- expect(setOpen).toHaveBeenCalledWith(false);
- });
});
diff --git a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx
index 0c29d05119..ea3040fe6d 100644
--- a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx
@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/cn";
+import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
-import { Modal } from "@/modules/ui/components/modal";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
@@ -457,26 +457,31 @@ export function AddFilterModal({
};
return (
-
-
- {
- setSearchValue(e.target.value);
- }}
- placeholder="Browse filters..."
- />
-
-
+
+
+
+
+ {
+ setSearchValue(e.target.value);
+ }}
+ placeholder="Browse filters..."
+ />
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/modules/ee/contacts/segments/components/create-segment-modal.test.tsx b/apps/web/modules/ee/contacts/segments/components/create-segment-modal.test.tsx
index d7e780bba9..d896e5b248 100644
--- a/apps/web/modules/ee/contacts/segments/components/create-segment-modal.test.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/create-segment-modal.test.tsx
@@ -27,16 +27,55 @@ vi.mock("@/modules/ee/contacts/segments/actions", () => ({
}));
// Mock child components that are complex or have their own tests
-vi.mock("@/modules/ui/components/modal", () => ({
- Modal: ({ open, setOpen, children, noPadding, closeOnOutsideClick, size, className }) =>
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({
+ open,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactNode;
+ }) =>
open ? (
-
+
{children}
- closeOnOutsideClick && setOpen(false)}>
- Close Outside
+ onOpenChange(false)}>
+ Close
) : null,
+ DialogContent: ({
+ children,
+ className,
+ disableCloseOnOutsideClick,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ disableCloseOnOutsideClick?: boolean;
+ }) => (
+
+ {children}
+
+ ),
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogDescription: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogBody: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogFooter: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
}));
vi.mock("./add-filter-modal", () => ({
@@ -84,12 +123,12 @@ describe("CreateSegmentModal", () => {
render(
);
const createButton = screen.getByText("common.create_segment");
expect(createButton).toBeInTheDocument();
- expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
await userEvent.click(createButton);
- expect(screen.getByTestId("modal")).toBeInTheDocument();
- expect(screen.getByText("common.create_segment", { selector: "h3" })).toBeInTheDocument(); // Modal title
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByText("common.create_segment", { selector: "h2" })).toBeInTheDocument(); // Modal title
});
test("closes modal on cancel button click", async () => {
@@ -97,11 +136,11 @@ describe("CreateSegmentModal", () => {
const createButton = screen.getByText("common.create_segment");
await userEvent.click(createButton);
- expect(screen.getByTestId("modal")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
const cancelButton = screen.getByText("common.cancel");
await userEvent.click(cancelButton);
- expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("updates title and description state on input change", async () => {
@@ -144,7 +183,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(openModalButton);
// Get modal and scope queries
- const modal = await screen.findByTestId("modal");
+ const modal = await screen.findByTestId("dialog");
// Find the save button using getByText with a specific selector within the modal
const saveButton = within(modal).getByText("common.create_segment", {
@@ -168,7 +207,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(createButton);
// Get modal and scope queries
- const modal = await screen.findByTestId("modal");
+ const modal = await screen.findByTestId("dialog");
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
const descriptionInput = within(modal).getByPlaceholderText(
@@ -196,7 +235,7 @@ describe("CreateSegmentModal", () => {
});
});
expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_saved_successfully");
- expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); // Modal should close on success
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); // Modal should close on success
});
test("shows error toast if createSegmentAction fails", async () => {
@@ -219,7 +258,7 @@ describe("CreateSegmentModal", () => {
});
expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse);
expect(toast.error).toHaveBeenCalledWith("Formatted API Error");
- expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open on error
+ expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open on error
});
test("shows generic error toast if Zod parsing succeeds during save error handling", async () => {
@@ -230,7 +269,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(openModalButton);
// Get the modal element
- const modal = await screen.findByTestId("modal");
+ const modal = await screen.findByTestId("dialog");
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
await userEvent.type(titleInput, "Generic Error Segment");
@@ -253,7 +292,7 @@ describe("CreateSegmentModal", () => {
// Now that we know the catch block ran, verify the action was called
expect(createSegmentAction).toHaveBeenCalled();
- expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open
+ expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open
});
test("opens AddFilterModal when 'Add Filter' button is clicked", async () => {
diff --git a/apps/web/modules/ee/contacts/segments/components/create-segment-modal.tsx b/apps/web/modules/ee/contacts/segments/components/create-segment-modal.tsx
index da35f06563..f7706d4d5d 100644
--- a/apps/web/modules/ee/contacts/segments/components/create-segment-modal.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/create-segment-modal.tsx
@@ -4,8 +4,16 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
-import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { FilterIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -132,41 +140,30 @@ export function CreateSegmentModal({
-
{
- handleResetState();
- }}
- size="lg">
-
-
-
-
-
-
-
-
-
{t("common.create_segment")}
-
- {t(
- "environments.segments.segments_help_you_target_users_with_same_characteristics_easily"
- )}
-
-
-
-
-
+ onOpenChange={(open) => {
+ if (!open) {
+ handleResetState();
+ }
+ }}>
+
+
+
+ {t("common.create_segment")}
+
+ {t("environments.segments.segments_help_you_target_users_with_same_characteristics_easily")}
+
+
-
+
+
+
+
+ {segment.filters.length === 0 && (
+
+
+
+ {t("environments.segments.add_your_first_filter_to_get_started")}
+
+
+ )}
-
-
- {segment.filters.length === 0 && (
-
-
-
- {t("environments.segments.add_your_first_filter_to_get_started")}
-
-
- )}
+
-
-
-
{
- setAddFilterModalOpen(true);
- }}
- size="sm"
- variant="secondary">
- {t("common.add_filter")}
-
-
-
{
- handleAddFilterInGroup(filter);
- }}
- open={addFilterModalOpen}
- segments={segments}
- setOpen={setAddFilterModalOpen}
- />
-
-
-
-
{
- handleResetState();
+ setAddFilterModalOpen(true);
}}
- type="button"
- variant="ghost">
- {t("common.cancel")}
+ size="sm"
+ variant="secondary">
+ {t("common.add_filter")}
-
{
- handleCreateSegment();
+
+ {
+ handleAddFilterInGroup(filter);
}}
- type="submit">
- {t("common.create_segment")}
-
+ open={addFilterModalOpen}
+ segments={segments}
+ setOpen={setAddFilterModalOpen}
+ />
-
-
-
+
+
+
+ {
+ handleResetState();
+ }}
+ type="button"
+ variant="secondary">
+ {t("common.cancel")}
+
+ {
+ handleCreateSegment();
+ }}
+ type="submit">
+ {t("common.create_segment")}
+
+
+
+
>
);
}
diff --git a/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.test.tsx b/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.test.tsx
index 427f155f1d..c279b44978 100644
--- a/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.test.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.test.tsx
@@ -1,32 +1,80 @@
import { EditSegmentModal } from "@/modules/ee/contacts/segments/components/edit-segment-modal";
import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
// Mock child components
vi.mock("@/modules/ee/contacts/segments/components/segment-settings", () => ({
- SegmentSettings: vi.fn(() =>
SegmentSettingsMock
),
+ SegmentSettings: vi.fn(() =>
SegmentSettingsMock
),
}));
vi.mock("@/modules/ee/contacts/segments/components/segment-activity-tab", () => ({
- SegmentActivityTab: vi.fn(() =>
SegmentActivityTabMock
),
+ SegmentActivityTab: vi.fn(() =>
SegmentActivityTabMock
),
}));
-vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
- ModalWithTabs: vi.fn(({ open, label, description, tabs, icon }) =>
+
+// Mock the Dialog components
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({
+ open,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactNode;
+ }) =>
open ? (
-
-
{label}
-
{description}
-
{icon}
-
- {tabs.map((tab) => (
- -
-
{tab.title}
- {tab.children}
-
- ))}
-
+
+ {children}
+ onOpenChange(false)}>
+ Close
+
- ) : null
+ ) : null,
+ DialogContent: ({
+ children,
+ disableCloseOnOutsideClick,
+ }: {
+ children: React.ReactNode;
+ disableCloseOnOutsideClick?: boolean;
+ }) => (
+
+ {children}
+
+ ),
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogDescription: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogBody: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+}));
+
+// Mock useTranslate
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({
+ t: (key: string) => {
+ const translations = {
+ "common.activity": "Activity",
+ "common.settings": "Settings",
+ };
+ return translations[key] || key;
+ },
+ }),
+}));
+
+// Mock lucide-react
+vi.mock("lucide-react", () => ({
+ UsersIcon: ({ className }: { className?: string }) => (
+
+ ๐ฅ
+
),
}));
@@ -62,77 +110,92 @@ describe("EditSegmentModal", () => {
vi.clearAllMocks();
});
- test("renders correctly when open and contacts enabled", async () => {
+ test("renders correctly when open and contacts enabled", () => {
render(
);
- expect(screen.getByText("Test Segment")).toBeInTheDocument();
- expect(screen.getByText("This is a test segment")).toBeInTheDocument();
- expect(screen.getByText("common.activity")).toBeInTheDocument();
- expect(screen.getByText("common.settings")).toBeInTheDocument();
- expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
- expect(screen.getByText("SegmentSettingsMock")).toBeInTheDocument();
-
- const ModalWithTabsMock = vi.mocked(
- await import("@/modules/ui/components/modal-with-tabs")
- ).ModalWithTabs;
-
- // Check that the mock was called
- expect(ModalWithTabsMock).toHaveBeenCalled();
-
- // Get the arguments of the first call
- const callArgs = ModalWithTabsMock.mock.calls[0];
- expect(callArgs).toBeDefined(); // Ensure the mock was called
-
- const propsPassed = callArgs[0]; // The first argument is the props object
-
- // Assert individual properties
- expect(propsPassed.open).toBe(true);
- expect(propsPassed.setOpen).toBe(defaultProps.setOpen);
- expect(propsPassed.label).toBe("Test Segment");
- expect(propsPassed.description).toBe("This is a test segment");
- expect(propsPassed.closeOnOutsideClick).toBe(false);
- expect(propsPassed.icon).toBeDefined(); // Check if icon exists
- expect(propsPassed.tabs).toHaveLength(2); // Check number of tabs
-
- // Check properties of the first tab
- expect(propsPassed.tabs[0].title).toBe("common.activity");
- expect(propsPassed.tabs[0].children).toBeDefined();
-
- // Check properties of the second tab
- expect(propsPassed.tabs[1].title).toBe("common.settings");
- expect(propsPassed.tabs[1].children).toBeDefined();
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
+ expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
+ expect(screen.getByTestId("users-icon")).toBeInTheDocument();
+ expect(screen.getByText("Activity")).toBeInTheDocument();
+ expect(screen.getByText("Settings")).toBeInTheDocument();
+ // Only the first tab (Activity) should be active initially
+ expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
+ expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
- test("renders correctly when open and contacts disabled", async () => {
+ test("renders correctly when open and contacts disabled", () => {
render(
);
- expect(screen.getByText("Test Segment")).toBeInTheDocument();
- expect(screen.getByText("This is a test segment")).toBeInTheDocument();
- expect(screen.getByText("common.activity")).toBeInTheDocument();
- expect(screen.getByText("common.settings")).toBeInTheDocument(); // Tab title still exists
- expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
- // Check that the settings content is not rendered, which is the key behavior
- expect(screen.queryByText("SegmentSettingsMock")).not.toBeInTheDocument();
-
- const ModalWithTabsMock = vi.mocked(
- await import("@/modules/ui/components/modal-with-tabs")
- ).ModalWithTabs;
- const calls = ModalWithTabsMock.mock.calls;
- const lastCallArgs = calls[calls.length - 1][0]; // Get the props of the last call
-
- // Check that the Settings tab was passed in props
- const settingsTab = lastCallArgs.tabs.find((tab) => tab.title === "common.settings");
- expect(settingsTab).toBeDefined();
- // The children prop will be
, but its rendered output is null/empty.
- // The check above (queryByText("SegmentSettingsMock")) already confirms this.
- // No need to check settingsTab.children === null here.
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
+ expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
+ expect(screen.getByText("Activity")).toBeInTheDocument();
+ expect(screen.getByText("Settings")).toBeInTheDocument();
+ expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
+ // Settings tab content should not render when contacts are disabled
+ expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("does not render when open is false", () => {
render(
);
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Test Segment")).not.toBeInTheDocument();
- expect(screen.queryByText("common.activity")).not.toBeInTheDocument();
- expect(screen.queryByText("common.settings")).not.toBeInTheDocument();
+ expect(screen.queryByText("Activity")).not.toBeInTheDocument();
+ expect(screen.queryByText("Settings")).not.toBeInTheDocument();
+ });
+
+ test("switches tabs correctly", async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ // Initially shows activity tab (first tab is active)
+ expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
+ expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
+
+ // Click settings tab
+ const settingsTab = screen.getByText("Settings");
+ await user.click(settingsTab);
+
+ // Now shows settings tab content
+ expect(screen.queryByTestId("segment-activity-tab")).not.toBeInTheDocument();
+ expect(screen.getByTestId("segment-settings")).toBeInTheDocument();
+
+ // Click activity tab again
+ const activityTab = screen.getByText("Activity");
+ await user.click(activityTab);
+
+ // Back to activity tab content
+ expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
+ expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
+ });
+
+ test("resets to first tab when modal is reopened", async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
);
+
+ // Switch to settings tab
+ const settingsTab = screen.getByText("Settings");
+ await user.click(settingsTab);
+ expect(screen.getByTestId("segment-settings")).toBeInTheDocument();
+
+ // Close modal
+ rerender(
);
+
+ // Reopen modal
+ rerender(
);
+
+ // Should be back to activity tab (first tab)
+ expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
+ expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
+ });
+
+ test("handles segment without description", () => {
+ const segmentWithoutDescription = { ...mockSegment, description: "" };
+ render(
);
+
+ expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
+ expect(screen.getByTestId("dialog-description")).toHaveTextContent("");
});
});
diff --git a/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.tsx b/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.tsx
index 311eb6b600..b86d17efb7 100644
--- a/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/edit-segment-modal.tsx
@@ -1,9 +1,17 @@
"use client";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
-import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
+import { useEffect, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { SegmentActivityTab } from "./segment-activity-tab";
@@ -30,6 +38,8 @@ export const EditSegmentModal = ({
isReadOnly,
}: EditSegmentModalProps) => {
const { t } = useTranslate();
+ const [activeTab, setActiveTab] = useState(0);
+
const SettingsTab = () => {
if (isContactsEnabled) {
return (
@@ -58,17 +68,42 @@ export const EditSegmentModal = ({
},
];
+ const handleTabClick = (index: number) => {
+ setActiveTab(index);
+ };
+
+ useEffect(() => {
+ if (!open) {
+ setActiveTab(0);
+ }
+ }, [open]);
+
return (
- <>
-
}
- label={currentSegment.title}
- description={currentSegment.description || ""}
- closeOnOutsideClick={false}
- />
- >
+
);
};
diff --git a/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx b/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx
index 90302d63ce..fb72f3ae1f 100644
--- a/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx
@@ -138,7 +138,7 @@ export function SegmentSettings({
}, [segment]);
return (
-
+
@@ -179,50 +179,51 @@ export function SegmentSettings({
-
-
- {segment.filters.length === 0 && (
-
-
-
- {t("environments.segments.add_your_first_filter_to_get_started")}
-
+
+
+
+ {segment.filters.length === 0 && (
+
+
+
+ {t("environments.segments.add_your_first_filter_to_get_started")}
+
+
+ )}
+
+
+
+
+ {
+ setAddFilterModalOpen(true);
+ }}
+ size="sm"
+ disabled={isReadOnly}
+ variant="secondary">
+ {t("common.add_filter")}
+
- )}
-
-
-
-
{
- setAddFilterModalOpen(true);
+ {
+ handleAddFilterInGroup(filter);
}}
- size="sm"
- disabled={isReadOnly}
- variant="secondary">
- {t("common.add_filter")}
-
+ open={addFilterModalOpen}
+ segments={segments}
+ setOpen={setAddFilterModalOpen}
+ />
-
-
{
- handleAddFilterInGroup(filter);
- }}
- open={addFilterModalOpen}
- segments={segments}
- setOpen={setAddFilterModalOpen}
- />
-
{!isReadOnly && (
<>
diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx
index 09671e52fc..f5f2d3b2cc 100644
--- a/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx
+++ b/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx
@@ -6,8 +6,24 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamModal } from "./create-team-modal";
-vi.mock("@/modules/ui/components/modal", () => ({
- Modal: ({ children }: any) =>
{children}
,
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
+ open ?
{children}
: null,
+ DialogContent: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogBody: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DialogFooter: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
@@ -24,9 +40,13 @@ describe("CreateTeamModal", () => {
const setOpen = vi.fn();
- test("renders modal, form, and tolgee strings", () => {
+ test("renders dialog, form, and tolgee strings", () => {
render(
);
- expect(screen.getByTestId("Modal")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
@@ -47,7 +67,7 @@ describe("CreateTeamModal", () => {
expect(screen.getByText("environments.settings.teams.create")).toBeDisabled();
});
- test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => {
+ test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes dialog on success", async () => {
vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" });
const onCreate = vi.fn();
render(
);
diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx
index 65dd5a0a0a..a8a94a074c 100644
--- a/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx
+++ b/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx
@@ -3,10 +3,16 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
-import { Modal } from "@/modules/ui/components/modal";
-import { H4 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -48,45 +54,45 @@ export const CreateTeamModal = ({ open, setOpen, organizationId, onCreate }: Cre
};
return (
-
-
-
-
-
-
{t("environments.settings.teams.create_new_team")}
-
-
-
-
-
+
);
};
diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx
index 629a45a35f..aa521450ab 100644
--- a/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx
+++ b/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx
@@ -42,7 +42,7 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
return (
<>
-
+
({
- Modal: ({ children, ...props }: any) => {children}
,
+// Mock the Dialog components
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({
+ open,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactNode;
+ }) =>
+ open ? (
+
+ {children}
+ onOpenChange(false)}>
+ Close
+
+
+ ) : null,
+ DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ DialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
}));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({
@@ -60,15 +101,15 @@ describe("TeamSettingsModal", () => {
currentUserId="1"
/>
);
- expect(screen.getByTestId("Modal")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument();
expect(screen.getByText("common.team_name")).toBeInTheDocument();
expect(screen.getByText("common.members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
expect(screen.getByText("Add member")).toBeInTheDocument();
- expect(screen.getByText("Projects")).toBeInTheDocument();
- expect(screen.getByText("Add project")).toBeInTheDocument();
+ expect(screen.getByText("common.projects")).toBeInTheDocument();
+ expect(screen.getByText("common.add_project")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx
index 88bf7706d8..fca7fe0642 100644
--- a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx
+++ b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx
@@ -1,6 +1,5 @@
"use client";
-import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
@@ -17,9 +16,17 @@ import {
} from "@/modules/ee/teams/team-list/types/team";
import { getTeamAccessFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
-import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -28,10 +35,10 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
-import { H4, Muted } from "@/modules/ui/components/typography";
+import { Muted } from "@/modules/ui/components/typography";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
-import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
+import { PlusIcon, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -196,44 +203,19 @@ export const TeamSettingsModal = ({
const hasEmptyProject = watchProjects.some((p) => !p.projectId);
return (
-
-
-
-
- Close
-
-
-
-
-
- {t("environments.settings.teams.team_name_settings_title", {
- teamName: team.name,
- })}
-
-
- {t("environments.settings.teams.team_settings_description")}
-
-
-
-
-
-
-