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 }) =>
,
+ 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",