diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.test.tsx new file mode 100644 index 0000000000..211228957c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.test.tsx @@ -0,0 +1,154 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types"; +import { AddressSummary } from "./AddressSummary"; + +// Mock dependencies +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/array-response", () => ({ + ArrayResponse: ({ value }: { value: string[] }) => ( +
{value.join(", ")}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("AddressSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + const locale = "en-US"; + + test("renders table headers correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + }); + + test("renders contact information correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [ + { + id: "response1", + value: ["123 Main St", "Apt 4", "New York", "NY", "10001"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: { email: "user@example.com" }, + }, + ], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1"); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("123 Main St, Apt 4, New York, NY, 10001"); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // Check link to contact + const contactLink = screen.getByText("contact@example.com").closest("a"); + expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [ + { + id: "response2", + value: ["456 Oak St", "London", "UK"], + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous"); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("456 Oak St, London, UK"); + }); + + test("renders multiple responses correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [ + { + id: "response1", + value: ["123 Main St", "New York"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + { + id: "response2", + value: ["456 Oak St", "London"], + updatedAt: new Date().toISOString(), + contact: { id: "contact2" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getAllByTestId("person-avatar")).toHaveLength(2); + expect(screen.getAllByTestId("array-response")).toHaveLength(2); + expect(screen.getAllByText("2 hours ago")).toHaveLength(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.test.tsx new file mode 100644 index 0000000000..aa92690d76 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types"; +import { CTASummary } from "./CTASummary"; + +vi.mock("@/modules/ui/components/progress-bar", () => ({ + ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => ( +
{`${progress}-${barColor}`}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ + additionalInfo, + }: { + showResponses: boolean; + additionalInfo: React.ReactNode; + }) =>
{additionalInfo}
, +})); + +vi.mock("lucide-react", () => ({ + InboxIcon: () =>
, +})); + +vi.mock("../lib/utils", () => ({ + convertFloatToNDecimal: (value: number) => value.toFixed(2), +})); + +describe("CTASummary", () => { + afterEach(() => { + cleanup(); + }); + + const survey = {} as TSurvey; + + test("renders with all metrics and required question", () => { + const questionSummary = { + question: { id: "q1", headline: "CTA Question", required: true }, + impressionCount: 100, + clickCount: 25, + skipCount: 10, + ctr: { count: 25, percentage: 25 }, + } as unknown as TSurveyQuestionSummaryCta; + + render(); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("100 common.impressions")).toBeInTheDocument(); + // Use getAllByText instead of getByText for multiple matching elements + expect(screen.getAllByText("25 common.clicks")).toHaveLength(2); + expect(screen.queryByText("10 common.skips")).not.toBeInTheDocument(); // Should not show skips for required questions + + // Check CTR section + expect(screen.getByText("CTR")).toBeInTheDocument(); + expect(screen.getByText("25.00%")).toBeInTheDocument(); + + // Check progress bar + expect(screen.getByTestId("progress-bar")).toHaveTextContent("0.25-bg-brand-dark"); + }); + + test("renders skip count for non-required questions", () => { + const questionSummary = { + question: { id: "q1", headline: "CTA Question", required: false }, + impressionCount: 100, + clickCount: 20, + skipCount: 30, + ctr: { count: 20, percentage: 20 }, + } as unknown as TSurveyQuestionSummaryCta; + + render(); + + expect(screen.getByText("30 common.skips")).toBeInTheDocument(); + }); + + test("renders singular form for count = 1", () => { + const questionSummary = { + question: { id: "q1", headline: "CTA Question", required: true }, + impressionCount: 10, + clickCount: 1, + skipCount: 0, + ctr: { count: 1, percentage: 10 }, + } as unknown as TSurveyQuestionSummaryCta; + + render(); + + // Use getAllByText instead of getByText for multiple matching elements + expect(screen.getAllByText("1 common.click")).toHaveLength(1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.test.tsx new file mode 100644 index 0000000000..f914246fc1 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types"; +import { CalSummary } from "./CalSummary"; + +vi.mock("@/modules/ui/components/progress-bar", () => ({ + ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => ( +
{`${progress}-${barColor}`}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +vi.mock("../lib/utils", () => ({ + convertFloatToNDecimal: (value: number) => value.toFixed(2), +})); + +describe("CalSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + + test("renders the correct components and data", () => { + const questionSummary = { + question: { id: "q1", headline: "Calendar Question" }, + booked: { count: 5, percentage: 75 }, + skipped: { count: 1, percentage: 25 }, + } as unknown as TSurveyQuestionSummaryCal; + + render(); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + + // Check if booked section is displayed + expect(screen.getByText("common.booked")).toBeInTheDocument(); + expect(screen.getByText("75.00%")).toBeInTheDocument(); + expect(screen.getByText("5 common.responses")).toBeInTheDocument(); + + // Check if skipped section is displayed + expect(screen.getByText("common.dismissed")).toBeInTheDocument(); + expect(screen.getByText("25.00%")).toBeInTheDocument(); + expect(screen.getByText("1 common.response")).toBeInTheDocument(); + + // Check progress bars + const progressBars = screen.getAllByTestId("progress-bar"); + expect(progressBars).toHaveLength(2); + expect(progressBars[0]).toHaveTextContent("0.75-bg-brand-dark"); + expect(progressBars[1]).toHaveTextContent("0.25-bg-brand-dark"); + }); + + test("renders singular and plural response counts correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Calendar Question" }, + booked: { count: 1, percentage: 50 }, + skipped: { count: 1, percentage: 50 }, + } as unknown as TSurveyQuestionSummaryCal; + + render(); + + // Use getAllByText directly since we know there are multiple matching elements + const responseElements = screen.getAllByText("1 common.response"); + expect(responseElements).toHaveLength(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.test.tsx new file mode 100644 index 0000000000..5ed1adfe41 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.test.tsx @@ -0,0 +1,153 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types"; +import { ContactInfoSummary } from "./ContactInfoSummary"; + +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/array-response", () => ({ + ArrayResponse: ({ value }: { value: string[] }) => ( +
{value.join(", ")}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("ContactInfoSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + const locale = "en-US"; + + test("renders table headers correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + }); + + test("renders contact information correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [ + { + id: "response1", + value: ["John Doe", "john@example.com", "+1234567890"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: { email: "user@example.com" }, + }, + ], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1"); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("John Doe, john@example.com, +1234567890"); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // Check link to contact + const contactLink = screen.getByText("contact@example.com").closest("a"); + expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [ + { + id: "response2", + value: ["Anonymous User", "anonymous@example.com"], + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous"); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("Anonymous User, anonymous@example.com"); + }); + + test("renders multiple responses correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [ + { + id: "response1", + value: ["John Doe", "john@example.com"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + { + id: "response2", + value: ["Jane Smith", "jane@example.com"], + updatedAt: new Date().toISOString(), + contact: { id: "contact2" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getAllByTestId("person-avatar")).toHaveLength(2); + expect(screen.getAllByTestId("array-response")).toHaveLength(2); + expect(screen.getAllByText("2 hours ago")).toHaveLength(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.test.tsx new file mode 100644 index 0000000000..904b846389 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.test.tsx @@ -0,0 +1,192 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types"; +import { DateQuestionSummary } from "./DateQuestionSummary"; + +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/lib/utils/datetime", () => ({ + formatDateWithOrdinal: (_: Date) => "January 1st, 2023", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("DateQuestionSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + const locale = "en-US"; + + test("renders table headers correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + }); + + test("renders date responses correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [ + { + id: "response1", + value: "2023-01-01", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByText("January 1st, 2023")).toBeInTheDocument(); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + }); + + test("renders invalid dates with special message", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [ + { + id: "response1", + value: "invalid-date", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByText("common.invalid_date(invalid-date)")).toBeInTheDocument(); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [ + { + id: "response1", + value: "2023-01-01", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + }); + + test("shows load more button when there are more responses and loads more on click", async () => { + const samples = Array.from({ length: 15 }, (_, i) => ({ + id: `response${i}`, + value: "2023-01-01", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + })); + + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples, + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByText("January 1st, 2023")).toHaveLength(10); + + // "Load More" button should be visible + const loadMoreButton = screen.getByTestId("load-more-button"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByText("January 1st, 2023")).toHaveLength(15); + + // "Load More" button should disappear + expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner.test.tsx new file mode 100644 index 0000000000..b64ab5ca5d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner.test.tsx @@ -0,0 +1,153 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { EnableInsightsBanner } from "./EnableInsightsBanner"; + +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + }, +})); + +vi.mock("@/modules/ee/insights/actions", () => ({ + generateInsightsForSurveyAction: vi.fn(), +})); + +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children, className }: { children: React.ReactNode; className: string }) => ( +
+ {children} +
+ ), + AlertTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDescription: ({ children, className }: { children: React.ReactNode; className: string }) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/badge", () => ({ + Badge: ({ type, size, text }: { type: string; size: string; text: string }) => ( + + {text} + + ), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ + size, + className, + onClick, + loading, + disabled, + children, + }: { + size: string; + className: string; + onClick: () => void; + loading: boolean; + disabled: boolean; + children: React.ReactNode; + }) => ( + + ), +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ + tooltipContent, + children, + }: { + tooltipContent: string | undefined; + children: React.ReactNode; + }) => ( +
+ {children} +
+ ), +})); + +vi.mock("lucide-react", () => ({ + SparklesIcon: ({ className, strokeWidth }: { className: string; strokeWidth: number }) => ( +
+ ), +})); + +describe("EnableInsightsBanner", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const surveyId = "survey-123"; + + test("renders banner with correct content", () => { + render(); + + expect(screen.getByTestId("alert")).toBeInTheDocument(); + expect(screen.getByTestId("alert-title")).toBeInTheDocument(); + expect(screen.getByTestId("badge")).toHaveTextContent("Beta"); + expect(screen.getByTestId("alert-description")).toBeInTheDocument(); + expect(screen.getByTestId("sparkles-icon")).toBeInTheDocument(); + expect(screen.getByTestId("button")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.summary.enable_ai_insights_banner_button") + ).toBeInTheDocument(); + }); + + test("disables button when response count exceeds maximum", () => { + render(); + + const button = screen.getByTestId("button"); + expect(button).toBeDisabled(); + + // Tooltip should have content when button is disabled + const tooltip = screen.getByTestId("tooltip"); + expect(tooltip).toHaveAttribute( + "data-content", + "environments.surveys.summary.enable_ai_insights_banner_tooltip" + ); + }); + + test("enables button when response count is within maximum", () => { + render(); + + const button = screen.getByTestId("button"); + expect(button).not.toBeDisabled(); + + // Tooltip should not have content when button is enabled + const tooltip = screen.getByTestId("tooltip"); + expect(tooltip).not.toHaveAttribute( + "data-content", + "environments.surveys.summary.enable_ai_insights_banner_tooltip" + ); + }); + + test("generates insights when button is clicked", async () => { + const { generateInsightsForSurveyAction } = await import("@/modules/ee/insights/actions"); + + render(); + + const button = screen.getByTestId("button"); + await userEvent.click(button); + + expect(toast.success).toHaveBeenCalledTimes(2); + expect(generateInsightsForSurveyAction).toHaveBeenCalledWith({ surveyId }); + + // Banner should disappear after generating insights + expect(screen.queryByTestId("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx new file mode 100644 index 0000000000..af062231ae --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx @@ -0,0 +1,231 @@ +import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + TSurvey, + TSurveyFileUploadQuestion, + TSurveyQuestionSummaryFileUpload, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; + +// Mock child components and hooks +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: vi.fn(() =>
PersonAvatarMock
), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: vi.fn(() =>
QuestionSummaryHeaderMock
), +})); + +// Mock utility functions +vi.mock("@/lib/storage/utils", () => ({ + getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`, +})); + +vi.mock("@/lib/time", () => ({ + timeSince: () => "some time ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +const environmentId = "test-env-id"; +const survey = { id: "survey-1" } as TSurvey; +const locale = "en-US"; + +const createMockResponse = (id: string, value: string[], contactId: string | null = null) => ({ + id: `response-${id}`, + value, + updatedAt: new Date().toISOString(), + contact: contactId ? { id: contactId, name: `Contact ${contactId}` } : null, + contactAttributes: contactId ? { email: `contact${contactId}@example.com` } : {}, +}); + +const questionSummaryBase = { + question: { + id: "q1", + headline: { default: "Upload your file" }, + type: TSurveyQuestionTypeEnum.FileUpload, + } as unknown as TSurveyFileUploadQuestion, + responseCount: 0, + files: [], +} as unknown as TSurveyQuestionSummaryFileUpload; + +describe("FileUploadSummary", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the component with initial responses", () => { + const files = Array.from({ length: 5 }, (_, i) => + createMockResponse(i.toString(), [`https://example.com/file${i}.pdf`], `contact-${i}`) + ); + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("QuestionSummaryHeaderMock")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(5); + expect(screen.getAllByText("contact@example.com")).toHaveLength(5); + expect(screen.getByText("original-file0.pdf")).toBeInTheDocument(); + expect(screen.getByText("original-file4.pdf")).toBeInTheDocument(); + expect(screen.queryByText("common.load_more")).not.toBeInTheDocument(); + }); + + test("renders 'Skipped' when value is an empty array", () => { + const files = [createMockResponse("skipped", [], "contact-skipped")]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("common.skipped")).toBeInTheDocument(); + expect(screen.queryByText(/original-/)).not.toBeInTheDocument(); // No file name should be rendered + }); + + test("renders 'Anonymous' when contact is null", () => { + const files = [createMockResponse("anon", ["https://example.com/anonfile.jpg"], null)]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByText("original-anonfile.jpg")).toBeInTheDocument(); + }); + + test("shows 'Load More' button when there are more than 10 responses and loads more on click", async () => { + const files = Array.from({ length: 15 }, (_, i) => + createMockResponse(i.toString(), [`https://example.com/file${i}.txt`], `contact-${i}`) + ); + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(10); + expect(screen.getByText("original-file9.txt")).toBeInTheDocument(); + expect(screen.queryByText("original-file10.txt")).not.toBeInTheDocument(); + + // "Load More" button should be visible + const loadMoreButton = screen.getByText("common.load_more"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(15); + expect(screen.getByText("original-file14.txt")).toBeInTheDocument(); + + // "Load More" button should disappear + expect(screen.queryByText("common.load_more")).not.toBeInTheDocument(); + }); + + test("renders multiple files for a single response", () => { + const files = [ + createMockResponse( + "multi", + ["https://example.com/fileA.png", "https://example.com/fileB.docx"], + "contact-multi" + ), + ]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("original-fileA.png")).toBeInTheDocument(); + expect(screen.getByText("original-fileB.docx")).toBeInTheDocument(); + // Check that download links exist + const links = screen.getAllByRole("link"); + // 1 contact link + 2 file links + expect(links.filter((link) => link.getAttribute("target") === "_blank")).toHaveLength(2); + expect( + links.find((link) => link.getAttribute("href") === "https://example.com/fileA.png") + ).toBeInTheDocument(); + expect( + links.find((link) => link.getAttribute("href") === "https://example.com/fileB.docx") + ).toBeInTheDocument(); + }); + + test("renders contact link correctly", () => { + const contactId = "contact-link-test"; + const files = [createMockResponse("link", ["https://example.com/link.pdf"], contactId)]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + const contactLink = screen.getByText("contact@example.com").closest("a"); + expect(contactLink).toBeInTheDocument(); + expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/${contactId}`); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index 0ae752f183..405c84b168 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -74,12 +74,12 @@ export const FileUploadSummary = ({
{Array.isArray(response.value) && (response.value.length > 0 ? ( - response.value.map((fileUrl, index) => { + response.value.map((fileUrl) => { const fileName = getOriginalFileNameFromUrl(fileUrl); return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.test.tsx new file mode 100644 index 0000000000..7924f943fb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.test.tsx @@ -0,0 +1,183 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types"; +import { HiddenFieldsSummary } from "./HiddenFieldsSummary"; + +// Mock dependencies +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})); + +// Mock lucide-react components +vi.mock("lucide-react", () => ({ + InboxIcon: () =>
, + MessageSquareTextIcon: () =>
, + Link: ({ children, href, className }: { children: React.ReactNode; href: string; className: string }) => ( + + {children} + + ), +})); + +// Mock Next.js Link +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +describe("HiddenFieldsSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environment = { id: "env-123" } as TEnvironment; + const locale = "en-US"; + + test("renders component with correct header and single response", () => { + const questionSummary = { + id: "hidden-field-1", + responseCount: 1, + samples: [ + { + id: "response1", + value: "Hidden value", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + expect(screen.getByText("hidden-field-1")).toBeInTheDocument(); + expect(screen.getByText("Hidden Field")).toBeInTheDocument(); + expect(screen.getByText("1 common.response")).toBeInTheDocument(); + + // Headers + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + + // We can skip checking for PersonAvatar as it's inside hidden md:flex + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByText("Hidden value")).toBeInTheDocument(); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // Check for link without checking for specific href + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + id: "hidden-field-1", + responseCount: 1, + samples: [ + { + id: "response1", + value: "Anonymous hidden value", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + // Instead of checking for avatar, just check for anonymous text + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByText("Anonymous hidden value")).toBeInTheDocument(); + }); + + test("renders plural response label when multiple responses", () => { + const questionSummary = { + id: "hidden-field-1", + responseCount: 2, + samples: [ + { + id: "response1", + value: "Hidden value 1", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + { + id: "response2", + value: "Hidden value 2", + updatedAt: new Date().toISOString(), + contact: { id: "contact2" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + expect(screen.getByText("2 common.responses")).toBeInTheDocument(); + expect(screen.getAllByText("contact@example.com")).toHaveLength(2); + }); + + test("shows load more button when there are more responses and loads more on click", async () => { + const samples = Array.from({ length: 15 }, (_, i) => ({ + id: `response${i}`, + value: `Hidden value ${i}`, + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + })); + + const questionSummary = { + id: "hidden-field-1", + responseCount: samples.length, + samples, + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(10); + + // "Load More" button should be visible + const loadMoreButton = screen.getByTestId("load-more-button"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(15); + + // "Load More" button should disappear + expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.test.tsx new file mode 100644 index 0000000000..d0bae10675 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.test.tsx @@ -0,0 +1,265 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types"; +import { OpenTextSummary } from "./OpenTextSummary"; + +// Mock dependencies +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/analysis/utils", () => ({ + renderHyperlinkedContent: (text: string) =>
{text}
, +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})); + +vi.mock("@/modules/ui/components/secondary-navigation", () => ({ + SecondaryNavigation: ({ activeId, navigation }: any) => ( +
+ {navigation.map((item: any) => ( + + ))} +
+ ), +})); + +vi.mock("@/modules/ui/components/table", () => ({ + Table: ({ children }: { children: React.ReactNode }) => {children}
, + TableHeader: ({ children }: { children: React.ReactNode }) => {children}, + TableBody: ({ children }: { children: React.ReactNode }) => {children}, + TableRow: ({ children }: { children: React.ReactNode }) => {children}, + TableHead: ({ children }: { children: React.ReactNode }) => {children}, + TableCell: ({ children, width }: { children: React.ReactNode; width?: number }) => ( + {children} + ), +})); + +vi.mock("@/modules/ee/insights/components/insights-view", () => ({ + InsightView: () =>
, +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ additionalInfo }: { additionalInfo?: React.ReactNode }) => ( +
{additionalInfo}
+ ), +})); + +describe("OpenTextSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = { id: "survey-1" } as TSurvey; + const locale = "en-US"; + + test("renders response mode by default when insights not enabled", () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + insightsEnabled: false, + insights: [], + samples: [ + { + id: "response1", + value: "Sample response text", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByTestId("table")).toBeInTheDocument(); + expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1"); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("hyperlinked-content")).toHaveTextContent("Sample response text"); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // No secondary navigation when insights not enabled + expect(screen.queryByTestId("secondary-navigation")).not.toBeInTheDocument(); + }); + + test("shows insights disabled message when AI is enabled but insights are disabled", () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + insightsEnabled: false, + insights: [], + samples: [], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + expect(screen.getByText("environments.surveys.summary.insights_disabled")).toBeInTheDocument(); + }); + + test("shows insights tab by default when insights are available", () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + insightsEnabled: true, + insights: [{ id: "insight1", text: "Insight text" }], + samples: [], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + expect(screen.getByTestId("secondary-navigation")).toBeInTheDocument(); + expect(screen.getByTestId("insight-view")).toBeInTheDocument(); + expect(screen.queryByTestId("table")).not.toBeInTheDocument(); + }); + + test("allows switching between insights and responses tabs", async () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + insightsEnabled: true, + insights: [{ id: "insight1", text: "Insight text" }], + samples: [ + { + id: "response1", + value: "Sample response text", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + // Initially showing insights + expect(screen.getByTestId("insight-view")).toBeInTheDocument(); + expect(screen.queryByTestId("table")).not.toBeInTheDocument(); + + // Click on responses tab + await userEvent.click(screen.getByText("common.responses")); + + // Now showing responses + expect(screen.queryByTestId("insight-view")).not.toBeInTheDocument(); + expect(screen.getByTestId("table")).toBeInTheDocument(); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + insightsEnabled: false, + insights: [], + samples: [ + { + id: "response1", + value: "Anonymous response", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous"); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + }); + + test("shows load more button when there are more responses and loads more on click", async () => { + const samples = Array.from({ length: 15 }, (_, i) => ({ + id: `response${i}`, + value: `Response ${i}`, + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + })); + + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + insightsEnabled: false, + insights: [], + samples, + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(10); + + // "Load More" button should be visible + const loadMoreButton = screen.getByTestId("load-more-button"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(15); + + // "Load More" button should disappear + expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.test.tsx new file mode 100644 index 0000000000..07374901cf --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.test.tsx @@ -0,0 +1,164 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummary, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; + +// Mock dependencies +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: () => ({ default: "Recalled Headline" }), +})); + +vi.mock("@/modules/survey/editor/lib/utils", () => ({ + formatTextWithSlashes: (text: string) => {text}, +})); + +vi.mock("@/modules/survey/lib/questions", () => ({ + getQuestionTypes: () => [ + { + id: "openText", + label: "Open Text", + icon: () =>
Icon
, + }, + { + id: "multipleChoice", + label: "Multiple Choice", + icon: () =>
Icon
, + }, + ], +})); + +vi.mock("@/modules/ui/components/settings-id", () => ({ + SettingsId: ({ title, id }: { title: string; id: string }) => ( +
+ {title}: {id} +
+ ), +})); + +// Mock InboxIcon +vi.mock("lucide-react", () => ({ + InboxIcon: () =>
, +})); + +describe("QuestionSummaryHeader", () => { + afterEach(() => { + cleanup(); + }); + + const survey = {} as TSurvey; + + test("renders header with question headline and type", () => { + const questionSummary = { + question: { + id: "q1", + headline: { default: "Test Question" }, + type: "openText" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 42, + } as unknown as TSurveyQuestionSummary; + + render(); + + expect(screen.getByTestId("formatted-headline")).toHaveTextContent("Recalled Headline"); + + // Look for text content with a more specific approach + const questionTypeElement = screen.getByText((content) => { + return content.includes("Open Text") && !content.includes("common.question_id"); + }); + expect(questionTypeElement).toBeInTheDocument(); + + // Check for responses text specifically + expect( + screen.getByText((content) => { + return content.includes("42") && content.includes("common.responses"); + }) + ).toBeInTheDocument(); + + expect(screen.getByTestId("question-icon")).toBeInTheDocument(); + expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1"); + expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument(); + }); + + test("shows 'optional' tag when question is not required", () => { + const questionSummary = { + question: { + id: "q2", + headline: { default: "Optional Question" }, + type: "multipleChoice" as TSurveyQuestionTypeEnum, + required: false, + }, + responseCount: 10, + } as unknown as TSurveyQuestionSummary; + + render(); + + expect(screen.getByText("environments.surveys.edit.optional")).toBeInTheDocument(); + }); + + test("hides response count when showResponses is false", () => { + const questionSummary = { + question: { + id: "q3", + headline: { default: "No Response Count Question" }, + type: "openText" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 15, + } as unknown as TSurveyQuestionSummary; + + render(); + + expect( + screen.queryByText((content) => content.includes("15") && content.includes("common.responses")) + ).not.toBeInTheDocument(); + }); + + test("shows unknown question type for unrecognized type", () => { + const questionSummary = { + question: { + id: "q4", + headline: { default: "Unknown Type Question" }, + type: "unknownType" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 5, + } as unknown as TSurveyQuestionSummary; + + render(); + + // Look for text in the question type element specifically + const unknownTypeElement = screen.getByText((content) => { + return ( + content.includes("environments.surveys.summary.unknown_question_type") && + !content.includes("common.question_id") + ); + }); + expect(unknownTypeElement).toBeInTheDocument(); + }); + + test("renders additional info when provided", () => { + const questionSummary = { + question: { + id: "q5", + headline: { default: "With Additional Info" }, + type: "openText" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 20, + } as unknown as TSurveyQuestionSummary; + + const additionalInfo =
Extra Information
; + + render( + + ); + + expect(screen.getByTestId("additional-info")).toBeInTheDocument(); + expect(screen.getByText("Extra Information")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx new file mode 100644 index 0000000000..69f080f1c7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx @@ -0,0 +1,104 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types"; +import { RankingSummary } from "./RankingSummary"; + +// Mock dependencies +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +vi.mock("../lib/utils", () => ({ + convertFloatToNDecimal: (value: number) => value.toFixed(2), +})); + +describe("RankingSummary", () => { + afterEach(() => { + cleanup(); + }); + + const survey = {} as TSurvey; + const surveyType: TSurveyType = "app"; + + test("renders ranking results in correct order", () => { + const questionSummary = { + question: { id: "q1", headline: "Rank the following" }, + choices: { + option1: { value: "Option A", avgRanking: 1.5, others: [] }, + option2: { value: "Option B", avgRanking: 2.3, others: [] }, + option3: { value: "Option C", avgRanking: 1.2, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + + // Check order: should be sorted by avgRanking (ascending) + const options = screen.getAllByText(/Option [A-C]/); + expect(options[0]).toHaveTextContent("Option C"); // 1.2 (lowest avgRanking first) + expect(options[1]).toHaveTextContent("Option A"); // 1.5 + expect(options[2]).toHaveTextContent("Option B"); // 2.3 + + // Check rankings are displayed + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("#2")).toBeInTheDocument(); + expect(screen.getByText("#3")).toBeInTheDocument(); + + // Check average values are displayed + expect(screen.getByText("#1.20")).toBeInTheDocument(); + expect(screen.getByText("#1.50")).toBeInTheDocument(); + expect(screen.getByText("#2.30")).toBeInTheDocument(); + }); + + test("renders 'other values found' section when others exist", () => { + const questionSummary = { + question: { id: "q1", headline: "Rank the following" }, + choices: { + option1: { + value: "Option A", + avgRanking: 1.0, + others: [{ value: "Other value", count: 2 }], + }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument(); + }); + + test("shows 'User' column in other values section for app survey type", () => { + const questionSummary = { + question: { id: "q1", headline: "Rank the following" }, + choices: { + option1: { + value: "Option A", + avgRanking: 1.0, + others: [{ value: "Other value", count: 1 }], + }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + expect(screen.getByText("common.user")).toBeInTheDocument(); + }); + + test("doesn't show 'User' column for link survey type", () => { + const questionSummary = { + question: { id: "q1", headline: "Rank the following" }, + choices: { + option1: { + value: "Option A", + avgRanking: 1.0, + others: [{ value: "Other value", count: 1 }], + }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + expect(screen.queryByText("common.user")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.test.tsx new file mode 100644 index 0000000000..52d5fe1e0d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types"; +import { SummaryDropOffs } from "./SummaryDropOffs"; + +// Mock dependencies +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: () => ({ default: "Recalled Question" }), +})); + +vi.mock("@/modules/survey/editor/lib/utils", () => ({ + formatTextWithSlashes: (text) => {text}, +})); + +vi.mock("@/modules/survey/lib/questions", () => ({ + getQuestionIcon: () => () =>
, +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("lucide-react", () => ({ + TimerIcon: () =>
, +})); + +describe("SummaryDropOffs", () => { + afterEach(() => { + cleanup(); + }); + + const mockSurvey = {} as TSurvey; + const mockDropOff: TSurveySummary["dropOff"] = [ + { + questionId: "q1", + headline: "First Question", + questionType: TSurveyQuestionTypeEnum.OpenText, + ttc: 15000, // 15 seconds + impressions: 100, + dropOffCount: 20, + dropOffPercentage: 20, + }, + { + questionId: "q2", + headline: "Second Question", + questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + ttc: 30000, // 30 seconds + impressions: 80, + dropOffCount: 15, + dropOffPercentage: 18.75, + }, + { + questionId: "q3", + headline: "Third Question", + questionType: TSurveyQuestionTypeEnum.Rating, + ttc: 0, // No time data + impressions: 65, + dropOffCount: 10, + dropOffPercentage: 15.38, + }, + ]; + + test("renders header row with correct columns", () => { + render(); + + // Check header + expect(screen.getByText("common.questions")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("timer-icon")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.drop_offs")).toBeInTheDocument(); + }); + + test("renders tooltip with correct content", () => { + render(); + + expect(screen.getByTestId("tooltip-content")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.ttc_tooltip")).toBeInTheDocument(); + }); + + test("renders all drop-off items with correct data", () => { + render(); + + // There should be 3 rows of data (one for each question) + expect(screen.getAllByTestId("question-icon")).toHaveLength(3); + expect(screen.getAllByTestId("formatted-text")).toHaveLength(3); + + // Check time to complete values + expect(screen.getByText("15.00s")).toBeInTheDocument(); // 15000ms converted to seconds + expect(screen.getByText("30.00s")).toBeInTheDocument(); // 30000ms converted to seconds + expect(screen.getByText("N/A")).toBeInTheDocument(); // 0ms shown as N/A + + // Check impressions values + expect(screen.getByText("100")).toBeInTheDocument(); + expect(screen.getByText("80")).toBeInTheDocument(); + expect(screen.getByText("65")).toBeInTheDocument(); + + // Check drop-off counts and percentages + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.getByText("(20%)")).toBeInTheDocument(); + + expect(screen.getByText("15")).toBeInTheDocument(); + expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19% + + expect(screen.getByText("10")).toBeInTheDocument(); + expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15% + }); + + test("renders empty state when dropOff array is empty", () => { + render(); + + // Header should still be visible + expect(screen.getByText("common.questions")).toBeInTheDocument(); + + // But no question icons + expect(screen.queryByTestId("question-icon")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.test.tsx new file mode 100644 index 0000000000..f69d5bcff3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.test.tsx @@ -0,0 +1,229 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { SummaryPage } from "./SummaryPage"; + +// Mock actions +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({ + getResponseCountAction: vi.fn().mockResolvedValue({ data: 42 }), + getSurveySummaryAction: vi.fn().mockResolvedValue({ + data: { + meta: { + completedPercentage: 80, + completedResponses: 40, + displayCount: 50, + dropOffPercentage: 20, + dropOffCount: 10, + startsPercentage: 100, + totalResponses: 50, + ttcAverage: 120, + }, + dropOff: [ + { + questionId: "q1", + headline: "Question 1", + questionType: "openText", + ttc: 20000, + impressions: 50, + dropOffCount: 5, + dropOffPercentage: 10, + }, + ], + summary: [ + { + question: { id: "q1", headline: "Question 1", type: "openText", required: true }, + responseCount: 45, + type: "openText", + samples: [], + }, + ], + }, + }), +})); + +vi.mock("@/app/share/[sharingKey]/actions", () => ({ + getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }), + getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({ + data: { + meta: { + completedPercentage: 80, + completedResponses: 40, + displayCount: 50, + dropOffPercentage: 20, + dropOffCount: 10, + startsPercentage: 100, + totalResponses: 50, + ttcAverage: 120, + }, + dropOff: [ + { + questionId: "q1", + headline: "Question 1", + questionType: "openText", + ttc: 20000, + impressions: 50, + dropOffCount: 5, + dropOffPercentage: 10, + }, + ], + summary: [ + { + question: { id: "q1", headline: "Question 1", type: "openText", required: true }, + responseCount: 45, + type: "openText", + samples: [], + }, + ], + }, + }), +})); + +// Mock components +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs", + () => ({ + SummaryDropOffs: () =>
DropOffs Component
, + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList", + () => ({ + SummaryList: ({ summary, responseCount }: any) => ( +
+ Response Count: {responseCount} + Summary Items: {summary.length} +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata", + () => ({ + SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => ( +
+ Is Loading: {isLoading ? "true" : "false"} + +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop", + () => ({ + __esModule: true, + default: () =>
Scroll To Top
, + }) +); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({ + CustomFilter: () =>
Custom Filter
, +})); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({ + ResultsShareButton: () =>
Share Results
, +})); + +// Mock context +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: () => ({ + selectedFilter: { filter: [], onlyComplete: false }, + dateRange: { from: null, to: null }, + resetState: vi.fn(), + }), +})); + +// Mock hooks +vi.mock("@/lib/utils/hooks/useIntervalWhenFocused", () => ({ + useIntervalWhenFocused: vi.fn(), +})); + +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: (survey: any) => survey, +})); + +vi.mock("next/navigation", () => ({ + useParams: () => ({}), + useSearchParams: () => ({ get: () => null }), +})); + +describe("SummaryPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockEnvironment = { id: "env-123" } as TEnvironment; + const mockSurvey = { + id: "survey-123", + environmentId: "env-123", + } as TSurvey; + const locale = "en-US" as TUserLocale; + + const defaultProps = { + environment: mockEnvironment, + survey: mockSurvey, + surveyId: "survey-123", + webAppUrl: "https://app.example.com", + totalResponseCount: 50, + isAIEnabled: true, + locale, + isReadOnly: false, + }; + + test("renders loading state initially", () => { + render(); + + expect(screen.getByTestId("summary-metadata")).toBeInTheDocument(); + expect(screen.getByText("Is Loading: true")).toBeInTheDocument(); + }); + + test("renders summary components after loading", async () => { + render(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByText("Is Loading: false")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("custom-filter")).toBeInTheDocument(); + expect(screen.getByTestId("results-share-button")).toBeInTheDocument(); + expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument(); + expect(screen.getByTestId("summary-list")).toBeInTheDocument(); + }); + + test("shows drop-offs component when toggled", async () => { + const user = userEvent.setup(); + render(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByText("Is Loading: false")).toBeInTheDocument(); + }); + + // Drop-offs should initially be hidden + expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument(); + + // Toggle drop-offs + await user.click(screen.getByText("Toggle Dropoffs")); + + // Drop-offs should now be visible + expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument(); + }); + + test("doesn't show share button in read-only mode", async () => { + render(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByText("Is Loading: false")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 0cfb336d66..16c9c5f1d2 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -47,7 +47,6 @@ export default defineConfig({ "app/layout.tsx", "app/intercom/*.tsx", "app/sentry/*.tsx", - "app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx", "app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx", "app/(app)/environments/**/surveys/**/(analysis)/summary/components/MatrixQuestionSummary.tsx", "app/(app)/environments/**/surveys/**/(analysis)/summary/components/MultipleChoiceSummary.tsx", @@ -55,6 +54,19 @@ export default defineConfig({ "app/(app)/environments/**/surveys/**/(analysis)/summary/components/PictureChoiceSummary.tsx", "app/(app)/environments/**/surveys/**/(analysis)/summary/components/RatingSummary.tsx", "app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryMetadata.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/FileUploadSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/AddressSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/CalSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/ContactInfoSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/CTASummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/DateQuestionSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/EnableInsightsBanner.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryDropOffs.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryPage.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/RankingSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/OpenTextSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/HiddenFieldsSummary.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/QuestionSummaryHeader.tsx", "app/(app)/environments/**/surveys/**/components/QuestionFilterComboBox.tsx", "app/(app)/environments/**/surveys/**/components/QuestionsComboBox.tsx", "app/(app)/environments/**/integrations/airtable/components/ManageIntegration.tsx",