From 0eb64c0084343a4a8998528f1cd7a373e511459b Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 8 May 2025 21:07:59 +0530 Subject: [PATCH] test: ui module test part 2 (#5716) --- .../components/RenderResponse.test.tsx | 6 +- .../components/RenderResponse.tsx | 6 +- .../highlighted-text/index.test.tsx | 62 +++ .../ui/components/iconbar/index.test.tsx | 147 ++++++++ .../components/input-combo-box/index.test.tsx | 264 +++++++++++++ .../ui/components/input/index.test.tsx | 93 +++++ .../web/modules/ui/components/input/index.tsx | 2 +- .../integration-card/index.test.tsx | 161 ++++++++ .../ui/components/label/index.test.tsx | 57 +++ .../limits-reached-banner/index.test.tsx | 119 ++++++ .../load-segment-modal/index.test.tsx | 243 ++++++++++++ .../components/loading-spinner/index.test.tsx | 64 ++++ .../modules/ui/components/logo/index.test.tsx | 40 ++ .../media-background/index.test.tsx | 211 +++++++++++ .../components/modal-with-tabs/index.test.tsx | 159 ++++++++ .../ui/components/modal/index.test.tsx | 192 ++++++++++ .../ui/components/multi-select/badge.test.tsx | 67 ++++ .../ui/components/multi-select/index.test.tsx | 218 +++++++++++ .../components/css-selector.test.tsx | 138 +++++++ .../components/inner-html-selector.test.tsx | 138 +++++++ .../components/page-url-selector.test.tsx | 253 +++++++++++++ .../components/page-url-selector.tsx | 2 +- .../no-code-action-form/index.test.tsx | 157 ++++++++ .../no-mobile-overlay/index.test.tsx | 38 ++ .../ui/components/option-card/index.test.tsx | 81 ++++ .../components/options-switch/index.test.tsx | 92 +++++ .../ui/components/otp-input/index.test.tsx | 119 ++++++ .../page-content-wrapper/index.test.tsx | 48 +++ .../ui/components/page-header/index.test.tsx | 58 +++ .../components/password-input/index.test.tsx | 83 ++++ .../pending-downgrade-banner/index.test.tsx | 118 ++++++ .../picture-selection-response/index.test.tsx | 77 ++++ .../ui/components/popover/index.test.tsx | 112 ++++++ .../components/tab-option.test.tsx | 61 +++ .../components/preview-survey/index.test.tsx | 356 ++++++++++++++++++ .../ui/components/preview-survey/index.tsx | 8 +- .../preview-survey/lib/utils.test.ts | 36 ++ .../ui/components/pro-badge/index.test.tsx | 50 +++ .../question-toggle-table/index.test.tsx | 322 ++++++++++++++++ .../ui/components/radio-group/index.test.tsx | 134 +++++++ .../ranking-response/index.test.tsx | 82 ++++ .../ui/components/ranking-response/index.tsx | 2 +- .../components/rating-response/index.test.tsx | 71 ++++ .../reset-progress-button/index.test.tsx | 53 +++ .../components/response-badges/index.test.tsx | 68 ++++ .../save-as-new-segment-modal/index.test.tsx | 230 +++++++++++ .../ui/components/search-bar/index.test.tsx | 45 +++ .../secondary-navigation/index.test.tsx | 67 ++++ .../components/segment-title/index.test.tsx | 58 +++ .../ui/components/select/index.test.tsx | 85 +++++ .../ui/components/settings-id/index.test.tsx | 35 ++ .../shuffle-option-select/index.test.tsx | 104 +++++ .../components/skeleton-loader/index.test.tsx | 73 ++++ .../ui/components/skeleton/index.test.tsx | 40 ++ .../ui/components/slider/index.test.tsx | 97 +++++ .../stacked-cards-container/index.test.tsx | 106 ++++++ .../ui/components/styling-tabs/index.test.tsx | 109 ++++++ .../survey-status-indicator/index.test.tsx | 169 +++++++++ .../ui/components/survey/index.test.tsx | 171 +++++++++ .../ui/components/switch/index.test.tsx | 112 ++++++ .../ui/components/tab-bar/index.test.tsx | 97 +++++ .../ui/components/tab-toggle/index.test.tsx | 121 ++++++ .../ui/components/table/index.test.tsx | 202 ++++++++++ .../modules/ui/components/tag/index.test.tsx | 40 ++ .../components/tags-combobox/index.test.tsx | 184 +++++++++ .../targeting-indicator/index.test.tsx | 93 +++++ .../index.test.tsx | 302 +++++++++++++++ .../components/toaster-client/index.test.tsx | 39 ++ .../ui/components/tooltip/index.test.tsx | 196 ++++++++++ .../ui/components/typography/index.test.tsx | 158 ++++++++ .../components/upgrade-prompt/index.test.tsx | 132 +++++++ apps/web/vite.config.mts | 1 + packages/types/surveys/types.ts | 2 + sonar-project.properties | 4 +- 74 files changed, 7925 insertions(+), 15 deletions(-) create mode 100644 apps/web/modules/ui/components/highlighted-text/index.test.tsx create mode 100644 apps/web/modules/ui/components/iconbar/index.test.tsx create mode 100644 apps/web/modules/ui/components/input-combo-box/index.test.tsx create mode 100644 apps/web/modules/ui/components/input/index.test.tsx create mode 100644 apps/web/modules/ui/components/integration-card/index.test.tsx create mode 100644 apps/web/modules/ui/components/label/index.test.tsx create mode 100644 apps/web/modules/ui/components/limits-reached-banner/index.test.tsx create mode 100644 apps/web/modules/ui/components/load-segment-modal/index.test.tsx create mode 100644 apps/web/modules/ui/components/loading-spinner/index.test.tsx create mode 100644 apps/web/modules/ui/components/logo/index.test.tsx create mode 100644 apps/web/modules/ui/components/media-background/index.test.tsx create mode 100644 apps/web/modules/ui/components/modal-with-tabs/index.test.tsx create mode 100644 apps/web/modules/ui/components/modal/index.test.tsx create mode 100644 apps/web/modules/ui/components/multi-select/badge.test.tsx create mode 100644 apps/web/modules/ui/components/multi-select/index.test.tsx create mode 100644 apps/web/modules/ui/components/no-code-action-form/components/css-selector.test.tsx create mode 100644 apps/web/modules/ui/components/no-code-action-form/components/inner-html-selector.test.tsx create mode 100644 apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx create mode 100644 apps/web/modules/ui/components/no-code-action-form/index.test.tsx create mode 100644 apps/web/modules/ui/components/no-mobile-overlay/index.test.tsx create mode 100644 apps/web/modules/ui/components/option-card/index.test.tsx create mode 100644 apps/web/modules/ui/components/options-switch/index.test.tsx create mode 100644 apps/web/modules/ui/components/otp-input/index.test.tsx create mode 100644 apps/web/modules/ui/components/page-content-wrapper/index.test.tsx create mode 100644 apps/web/modules/ui/components/page-header/index.test.tsx create mode 100644 apps/web/modules/ui/components/password-input/index.test.tsx create mode 100644 apps/web/modules/ui/components/pending-downgrade-banner/index.test.tsx create mode 100644 apps/web/modules/ui/components/picture-selection-response/index.test.tsx create mode 100644 apps/web/modules/ui/components/popover/index.test.tsx create mode 100644 apps/web/modules/ui/components/preview-survey/components/tab-option.test.tsx create mode 100644 apps/web/modules/ui/components/preview-survey/index.test.tsx create mode 100644 apps/web/modules/ui/components/preview-survey/lib/utils.test.ts create mode 100644 apps/web/modules/ui/components/pro-badge/index.test.tsx create mode 100644 apps/web/modules/ui/components/question-toggle-table/index.test.tsx create mode 100644 apps/web/modules/ui/components/radio-group/index.test.tsx create mode 100644 apps/web/modules/ui/components/ranking-response/index.test.tsx create mode 100644 apps/web/modules/ui/components/rating-response/index.test.tsx create mode 100644 apps/web/modules/ui/components/reset-progress-button/index.test.tsx create mode 100644 apps/web/modules/ui/components/response-badges/index.test.tsx create mode 100644 apps/web/modules/ui/components/save-as-new-segment-modal/index.test.tsx create mode 100644 apps/web/modules/ui/components/search-bar/index.test.tsx create mode 100644 apps/web/modules/ui/components/secondary-navigation/index.test.tsx create mode 100644 apps/web/modules/ui/components/segment-title/index.test.tsx create mode 100644 apps/web/modules/ui/components/select/index.test.tsx create mode 100644 apps/web/modules/ui/components/settings-id/index.test.tsx create mode 100644 apps/web/modules/ui/components/shuffle-option-select/index.test.tsx create mode 100644 apps/web/modules/ui/components/skeleton-loader/index.test.tsx create mode 100644 apps/web/modules/ui/components/skeleton/index.test.tsx create mode 100644 apps/web/modules/ui/components/slider/index.test.tsx create mode 100644 apps/web/modules/ui/components/stacked-cards-container/index.test.tsx create mode 100644 apps/web/modules/ui/components/styling-tabs/index.test.tsx create mode 100644 apps/web/modules/ui/components/survey-status-indicator/index.test.tsx create mode 100644 apps/web/modules/ui/components/survey/index.test.tsx create mode 100644 apps/web/modules/ui/components/switch/index.test.tsx create mode 100644 apps/web/modules/ui/components/tab-bar/index.test.tsx create mode 100644 apps/web/modules/ui/components/tab-toggle/index.test.tsx create mode 100644 apps/web/modules/ui/components/table/index.test.tsx create mode 100644 apps/web/modules/ui/components/tag/index.test.tsx create mode 100644 apps/web/modules/ui/components/tags-combobox/index.test.tsx create mode 100644 apps/web/modules/ui/components/targeting-indicator/index.test.tsx create mode 100644 apps/web/modules/ui/components/theme-styling-preview-survey/index.test.tsx create mode 100644 apps/web/modules/ui/components/toaster-client/index.test.tsx create mode 100644 apps/web/modules/ui/components/tooltip/index.test.tsx create mode 100644 apps/web/modules/ui/components/typography/index.test.tsx create mode 100644 apps/web/modules/ui/components/upgrade-prompt/index.test.tsx diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx index 22d4b19996..8a4e40bc2e 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx @@ -25,7 +25,7 @@ vi.mock("@/modules/ui/components/response-badges", () => ({ ResponseBadges: ({ items }: any) =>
{items.join(",")}
, })); vi.mock("@/modules/ui/components/ranking-response", () => ({ - RankingRespone: ({ value }: any) =>
{value.join(",")}
, + RankingResponse: ({ value }: any) =>
{value.join(",")}
, })); vi.mock("@/modules/analysis/utils", () => ({ renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text), @@ -236,7 +236,7 @@ describe("RenderResponse", () => { expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9"); }); - test("renders RankingRespone for 'Ranking' question", () => { + test("renders RankingResponse for 'Ranking' question", () => { const question = { ...defaultQuestion, type: "ranking" }; render( { language={dummyLanguage} /> ); - expect(screen.getByTestId("RankingRespone")).toHaveTextContent("first,second"); + expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first,second"); }); test("renders default branch for unknown question type with string", () => { diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx index 6b7b4ab8bf..2250f9b33e 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx @@ -7,7 +7,7 @@ import { renderHyperlinkedContent } from "@/modules/analysis/utils"; import { ArrayResponse } from "@/modules/ui/components/array-response"; import { FileUploadResponse } from "@/modules/ui/components/file-upload-response"; import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response"; -import { RankingRespone } from "@/modules/ui/components/ranking-response"; +import { RankingResponse } from "@/modules/ui/components/ranking-response"; import { RatingResponse } from "@/modules/ui/components/rating-response"; import { ResponseBadges } from "@/modules/ui/components/response-badges"; import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react"; @@ -101,7 +101,7 @@ export const RenderResponse: React.FC = ({ return (

+ className="ph-no-capture my-1 font-normal capitalize text-slate-700"> {rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}

); @@ -161,7 +161,7 @@ export const RenderResponse: React.FC = ({ break; case TSurveyQuestionTypeEnum.Ranking: if (Array.isArray(responseData)) { - return ; + return ; } default: if ( diff --git a/apps/web/modules/ui/components/highlighted-text/index.test.tsx b/apps/web/modules/ui/components/highlighted-text/index.test.tsx new file mode 100644 index 0000000000..4f0953c6e8 --- /dev/null +++ b/apps/web/modules/ui/components/highlighted-text/index.test.tsx @@ -0,0 +1,62 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { HighlightedText } from "./index"; + +describe("HighlightedText", () => { + afterEach(() => { + cleanup(); + }); + + test("renders text without highlighting when search value is empty", () => { + render(); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + expect(screen.queryByRole("mark")).not.toBeInTheDocument(); + }); + + test("renders text without highlighting when search value is just whitespace", () => { + render(); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + expect(screen.queryByRole("mark")).not.toBeInTheDocument(); + }); + + test("highlights matching text when search value is provided", () => { + const { container } = render(); + const markElement = container.querySelector("mark"); + expect(markElement).toBeInTheDocument(); + expect(markElement?.textContent).toBe("world"); + expect(container.textContent).toBe("Hello world"); + }); + + test("highlights all instances of matching text", () => { + const { container } = render(); + const markElements = container.querySelectorAll("mark"); + expect(markElements).toHaveLength(2); + expect(markElements[0].textContent?.toLowerCase()).toBe("hello"); + expect(markElements[1].textContent?.toLowerCase()).toBe("hello"); + }); + + test("handles case insensitive matches", () => { + const { container } = render(); + const markElement = container.querySelector("mark"); + expect(markElement).toBeInTheDocument(); + expect(markElement?.textContent).toBe("World"); + }); + + test("escapes special regex characters in search value", () => { + const { container } = render(); + const markElement = container.querySelector("mark"); + expect(markElement).toBeInTheDocument(); + expect(markElement?.textContent).toBe("(world)"); + }); + + test("maintains the correct order of text fragments", () => { + const { container } = render(); + expect(container.textContent).toBe("apple banana apple"); + + const markElements = container.querySelectorAll("mark"); + expect(markElements).toHaveLength(2); + expect(markElements[0].textContent).toBe("apple"); + expect(markElements[1].textContent).toBe("apple"); + }); +}); diff --git a/apps/web/modules/ui/components/iconbar/index.test.tsx b/apps/web/modules/ui/components/iconbar/index.test.tsx new file mode 100644 index 0000000000..752f12d0d7 --- /dev/null +++ b/apps/web/modules/ui/components/iconbar/index.test.tsx @@ -0,0 +1,147 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { IconBar } from "./index"; + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => ( +
+ {children} +
+ ), +})); + +vi.mock("../button", () => ({ + Button: ({ children, onClick, className, size, "aria-label": ariaLabel }: any) => ( + + ), +})); + +describe("IconBar", () => { + afterEach(() => { + cleanup(); + }); + + test("renders nothing when actions array is empty", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("renders only visible actions", () => { + const MockIcon1 = () =>
Icon 1
; + const MockIcon2 = () =>
Icon 2
; + + const actions = [ + { + icon: MockIcon1, + tooltip: "Action 1", + onClick: vi.fn(), + isVisible: true, + }, + { + icon: MockIcon2, + tooltip: "Action 2", + onClick: vi.fn(), + isVisible: false, + }, + ]; + + render(); + + expect(screen.getByRole("toolbar")).toBeInTheDocument(); + expect(screen.getByTestId("mock-icon-1")).toBeInTheDocument(); + expect(screen.queryByTestId("mock-icon-2")).not.toBeInTheDocument(); + }); + + test("renders multiple actions correctly", () => { + const MockIcon1 = () =>
Icon 1
; + const MockIcon2 = () =>
Icon 2
; + + const actions = [ + { + icon: MockIcon1, + tooltip: "Action 1", + onClick: vi.fn(), + isVisible: true, + }, + { + icon: MockIcon2, + tooltip: "Action 2", + onClick: vi.fn(), + isVisible: true, + }, + ]; + + render(); + + expect(screen.getAllByTestId("tooltip")).toHaveLength(2); + expect(screen.getByTestId("mock-icon-1")).toBeInTheDocument(); + expect(screen.getByTestId("mock-icon-2")).toBeInTheDocument(); + }); + + test("triggers onClick handler when button is clicked", async () => { + const user = userEvent.setup(); + const MockIcon = () =>
Icon
; + const handleClick = vi.fn(); + + const actions = [ + { + icon: MockIcon, + tooltip: "Action", + onClick: handleClick, + isVisible: true, + }, + ]; + + render(); + + const button = screen.getByTestId("button"); + await user.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test("renders tooltip with correct content", () => { + const MockIcon = () =>
Icon
; + + const actions = [ + { + icon: MockIcon, + tooltip: "Test Tooltip", + onClick: vi.fn(), + isVisible: true, + }, + ]; + + render(); + + const tooltip = screen.getByTestId("tooltip"); + expect(tooltip).toHaveAttribute("data-tooltip", "Test Tooltip"); + }); + + test("sets aria-label on button correctly", () => { + const MockIcon = () =>
Icon
; + + const actions = [ + { + icon: MockIcon, + tooltip: "Test Tooltip", + onClick: vi.fn(), + isVisible: true, + }, + ]; + + render(); + + const button = screen.getByTestId("button"); + expect(button).toHaveAttribute("aria-label", "Test Tooltip"); + }); +}); diff --git a/apps/web/modules/ui/components/input-combo-box/index.test.tsx b/apps/web/modules/ui/components/input-combo-box/index.test.tsx new file mode 100644 index 0000000000..ddad5cd9bb --- /dev/null +++ b/apps/web/modules/ui/components/input-combo-box/index.test.tsx @@ -0,0 +1,264 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LucideSettings, User } from "lucide-react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { InputCombobox, TComboboxOption } from "./index"; + +// Mock components used by InputCombobox +vi.mock("@/modules/ui/components/command", () => ({ + Command: ({ children, className }: any) => ( +
+ {children} +
+ ), + CommandInput: ({ placeholder, className }: any) => ( + + ), + CommandList: ({ children, className }: any) => ( +
+ {children} +
+ ), + CommandEmpty: ({ children, className }: any) => ( +
+ {children} +
+ ), + CommandGroup: ({ children, heading }: any) => ( +
+ {children} +
+ ), + CommandItem: ({ children, onSelect, className }: any) => ( +
+ {children} +
+ ), + CommandSeparator: ({ className }: any) =>
, +})); + +vi.mock("@/modules/ui/components/popover", () => ({ + Popover: ({ children, open, onOpenChange }: any) => ( +
+ {children} + +
+ ), + PopoverTrigger: ({ children, asChild }: any) => ( +
+ {children} +
+ ), + PopoverContent: ({ children, className }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ id, className, value, onChange, ...props }: any) => ( + + ), +})); + +vi.mock("next/image", () => ({ + default: ({ src, alt, width, height, className }: any) => ( + {alt} + ), +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("InputCombobox", () => { + afterEach(() => { + cleanup(); + }); + + const mockOptions: TComboboxOption[] = [ + { label: "Option 1", value: "opt1" }, + { label: "Option 2", value: "opt2" }, + { icon: User, label: "User Option", value: "user" }, + { imgSrc: "/test-image.jpg", label: "Image Option", value: "img" }, + ]; + + const mockGroupedOptions = [ + { + label: "Group 1", + value: "group1", + options: [ + { label: "Group 1 Option 1", value: "g1opt1" }, + { label: "Group 1 Option 2", value: "g1opt2" }, + ], + }, + { + label: "Group 2", + value: "group2", + options: [ + { label: "Group 2 Option 1", value: "g2opt1" }, + { icon: LucideSettings, label: "Settings", value: "settings" }, + ], + }, + ]; + + test("renders with default props", () => { + render( {}} />); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByTestId("popover")).toBeInTheDocument(); + expect(screen.getByTestId("command-input")).toBeInTheDocument(); + }); + + test("renders without search when showSearch is false", () => { + render( + {}} showSearch={false} /> + ); + expect(screen.queryByTestId("command-input")).not.toBeInTheDocument(); + }); + + test("renders with options", () => { + render( {}} />); + expect(screen.getAllByTestId("command-item")).toHaveLength(mockOptions.length); + }); + + test("renders with grouped options", () => { + render( {}} />); + expect(screen.getAllByTestId("command-group")).toHaveLength(mockGroupedOptions.length); + expect(screen.getByTestId("command-separator")).toBeInTheDocument(); + }); + + test("renders with input when withInput is true", () => { + render( {}} withInput={true} />); + expect(screen.getByTestId("input")).toBeInTheDocument(); + }); + + test("handles option selection", async () => { + const user = userEvent.setup(); + const onChangeValue = vi.fn(); + + render(); + + // Toggle popover to open dropdown + await user.click(screen.getByTestId("toggle-popover")); + + // Click on an option + const items = screen.getAllByTestId("command-item"); + await user.click(items[0]); + + expect(onChangeValue).toHaveBeenCalledWith("opt1", expect.objectContaining({ value: "opt1" })); + }); + + test("handles multi-select", async () => { + const user = userEvent.setup(); + const onChangeValue = vi.fn(); + + render( + + ); + + // Toggle popover to open dropdown + await user.click(screen.getByTestId("toggle-popover")); + + // Click on an option + const items = screen.getAllByTestId("command-item"); + await user.click(items[0]); + + expect(onChangeValue).toHaveBeenCalledWith(["opt1"], expect.objectContaining({ value: "opt1" })); + + // Click on another option + await user.click(items[1]); + + expect(onChangeValue).toHaveBeenCalledWith(["opt1", "opt2"], expect.objectContaining({ value: "opt2" })); + }); + + test("handles input change when withInput is true", async () => { + const user = userEvent.setup(); + const onChangeValue = vi.fn(); + + render( + + ); + + const input = screen.getByTestId("input"); + await user.type(input, "test"); + + expect(onChangeValue).toHaveBeenCalledWith("test", undefined, true); + }); + + test("renders with clearable option and handles clear", async () => { + const user = userEvent.setup(); + const onChangeValue = vi.fn(); + + const { rerender } = render( + + ); + + // Select an option first to show the clear button + await user.click(screen.getByTestId("toggle-popover")); + const items = screen.getAllByTestId("command-item"); + await user.click(items[0]); + + // Rerender with the selected value + rerender( + + ); + + // Find and click the X icon (simulated) + const clearButton = screen.getByText("Toggle Popover"); + await user.click(clearButton); + + // Verify onChangeValue was called + expect(onChangeValue).toHaveBeenCalled(); + }); + + test("renders custom empty dropdown text", () => { + render( + {}} + emptyDropdownText="custom.empty.text" + /> + ); + + expect(screen.getByTestId("command-empty").textContent).toBe("custom.empty.text"); + }); + + test("renders with value pre-selected", () => { + render( {}} />); + + expect(screen.getByRole("combobox")).toHaveTextContent("Option 1"); + }); + + test("handles icons and images in options", () => { + render( {}} />); + + // Should render the User icon for the selected option + expect(screen.getByRole("combobox")).toHaveTextContent("User Option"); + }); +}); diff --git a/apps/web/modules/ui/components/input/index.test.tsx b/apps/web/modules/ui/components/input/index.test.tsx new file mode 100644 index 0000000000..5054468b64 --- /dev/null +++ b/apps/web/modules/ui/components/input/index.test.tsx @@ -0,0 +1,93 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import { Input } from "./index"; + +describe("Input", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default props", () => { + render(); + const input = screen.getByTestId("test-input"); + expect(input).toBeInTheDocument(); + expect(input).toHaveClass("flex h-10 w-full rounded-md border border-slate-300"); + }); + + test("applies additional className when provided", () => { + render(); + const input = screen.getByTestId("test-input"); + expect(input).toHaveClass("test-class"); + }); + + test("renders with invalid styling when isInvalid is true", () => { + render(); + const input = screen.getByTestId("test-input"); + expect(input).toHaveClass("border-red-500"); + }); + + test("forwards ref to input element", () => { + const inputRef = React.createRef(); + render(); + expect(inputRef.current).not.toBeNull(); + expect(inputRef.current).toBe(screen.getByTestId("test-input")); + }); + + test("applies disabled styles when disabled prop is provided", () => { + render(); + const input = screen.getByTestId("test-input"); + expect(input).toBeDisabled(); + expect(input).toHaveClass("disabled:cursor-not-allowed disabled:opacity-50"); + }); + + test("handles user input correctly", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByTestId("test-input"); + + await user.type(input, "hello"); + expect(input).toHaveValue("hello"); + }); + + test("handles value prop correctly", () => { + render(); + const input = screen.getByTestId("test-input"); + expect(input).toHaveValue("test-value"); + }); + + test("handles placeholder prop correctly", () => { + render(); + const input = screen.getByTestId("test-input"); + expect(input).toHaveAttribute("placeholder", "test-placeholder"); + }); + + test("passes HTML attributes to the input element", () => { + render( + + ); + const input = screen.getByTestId("test-input"); + expect(input).toHaveAttribute("type", "password"); + expect(input).toHaveAttribute("name", "password"); + expect(input).toHaveAttribute("maxLength", "10"); + expect(input).toHaveAttribute("aria-label", "Password input"); + }); + + test("applies focus styles on focus", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByTestId("test-input"); + + expect(input).not.toHaveFocus(); + await user.click(input); + expect(input).toHaveFocus(); + }); +}); diff --git a/apps/web/modules/ui/components/input/index.tsx b/apps/web/modules/ui/components/input/index.tsx index c83f9101e9..6aeb86b4ce 100644 --- a/apps/web/modules/ui/components/input/index.tsx +++ b/apps/web/modules/ui/components/input/index.tsx @@ -14,7 +14,7 @@ const Input = React.forwardRef(({ className, isInv return ( ({ + default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => ( + + {children} + + ), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, disabled, size, variant }: any) => ( + + ), +})); + +describe("Integration Card", () => { + afterEach(() => { + cleanup(); + }); + + test("renders basic card with label and description", () => { + render(); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + }); + + test("renders icon when provided", () => { + const testIcon =
Icon
; + render(); + + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + test("renders connect button with link when connectHref is provided", () => { + render( + + ); + + const button = screen.getByTestId("mock-button"); + expect(button).toBeInTheDocument(); + + const link = screen.getByTestId("mock-link"); + expect(link).toHaveAttribute("href", "/connect"); + expect(link).toHaveTextContent("Connect"); + }); + + test("renders docs button with link when docsHref is provided", () => { + render( + + ); + + const button = screen.getByTestId("mock-button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-variant", "secondary"); + + const link = screen.getByTestId("mock-link"); + expect(link).toHaveAttribute("href", "/docs"); + expect(link).toHaveTextContent("Documentation"); + }); + + test("renders both connect and docs buttons when both hrefs are provided", () => { + render( + + ); + + const buttons = screen.getAllByTestId("mock-button"); + expect(buttons).toHaveLength(2); + + const links = screen.getAllByTestId("mock-link"); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute("href", "/connect"); + expect(links[1]).toHaveAttribute("href", "/docs"); + }); + + test("sets target to _blank when connectNewTab is true", () => { + render( + + ); + + const link = screen.getByTestId("mock-link"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + test("sets target to _blank when docsNewTab is true", () => { + render( + + ); + + const link = screen.getByTestId("mock-link"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + test("renders status text with green indicator when connected is true", () => { + render( + + ); + + expect(screen.getByText("Connected")).toBeInTheDocument(); + // Check for green indicator by inspecting the span with the animation class + const container = screen.getByText("Connected").parentElement; + const animatedSpan = container?.querySelector(".animate-ping-slow"); + expect(animatedSpan).toBeInTheDocument(); + }); + + test("renders status text with gray indicator when connected is false", () => { + render( + + ); + + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + // Check for gray indicator by inspecting the span without the animation class + const container = screen.getByText("Disconnected").parentElement; + const graySpan = container?.querySelector(".bg-slate-400"); + expect(graySpan).toBeInTheDocument(); + }); + + test("disables buttons when disabled prop is true", () => { + render( + + ); + + const buttons = screen.getAllByTestId("mock-button"); + buttons.forEach((button) => { + expect(button).toHaveAttribute("disabled"); + }); + }); +}); diff --git a/apps/web/modules/ui/components/label/index.test.tsx b/apps/web/modules/ui/components/label/index.test.tsx new file mode 100644 index 0000000000..e3eec813c3 --- /dev/null +++ b/apps/web/modules/ui/components/label/index.test.tsx @@ -0,0 +1,57 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Label } from "./index"; + +// Mock Radix UI Label primitive +vi.mock("@radix-ui/react-label", () => ({ + Root: ({ children, className, htmlFor, ...props }: any) => ( + + ), +})); + +describe("Label", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default styling", () => { + render(); + + const label = screen.getByTestId("radix-label"); + expect(label).toBeInTheDocument(); + expect(label).toHaveTextContent("Test Label"); + expect(label).toHaveClass("text-sm", "leading-none", "font-medium", "text-slate-800"); + }); + + test("applies additional className when provided", () => { + render(); + + const label = screen.getByTestId("radix-label"); + expect(label).toHaveClass("custom-class"); + expect(label).toHaveClass("text-sm", "leading-none", "font-medium", "text-slate-800"); + }); + + test("forwards ref to underlying label element", () => { + const ref = React.createRef(); + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toBe(screen.getByTestId("radix-label")); + }); + + test("passes additional props to underlying label element", () => { + render( + + ); + + const label = screen.getByTestId("radix-label"); + expect(label).toHaveAttribute("data-custom", "test-data"); + expect(label).toHaveAttribute("id", "test-id"); + }); +}); diff --git a/apps/web/modules/ui/components/limits-reached-banner/index.test.tsx b/apps/web/modules/ui/components/limits-reached-banner/index.test.tsx new file mode 100644 index 0000000000..85f0ea5f9e --- /dev/null +++ b/apps/web/modules/ui/components/limits-reached-banner/index.test.tsx @@ -0,0 +1,119 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { LimitsReachedBanner } from "./index"; + +// Mock the next/link component +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +describe("LimitsReachedBanner", () => { + afterEach(() => { + cleanup(); + }); + + const mockOrganization: TOrganization = { + id: "org-123", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: "free", + period: "monthly", + periodStart: new Date(), + stripeCustomerId: null, + limits: { + monthly: { + responses: 100, + miu: 100, + }, + projects: 1, + }, + }, + isAIEnabled: false, + }; + + const defaultProps = { + organization: mockOrganization, + environmentId: "env-123", + peopleCount: 0, + responseCount: 0, + }; + + test("does not render when no limits are reached", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("renders when people limit is reached", () => { + const peopleCount = 100; + render(); + + expect(screen.getByText("common.limits_reached")).toBeInTheDocument(); + + const learnMoreLink = screen.getByTestId("mock-link"); + expect(learnMoreLink).toHaveAttribute("href", "/environments/env-123/settings/billing"); + expect(learnMoreLink.textContent).toBe("common.learn_more"); + }); + + test("renders when response limit is reached", () => { + const responseCount = 100; + render(); + + expect(screen.getByText("common.limits_reached")).toBeInTheDocument(); + }); + + test("renders when both limits are reached", () => { + const peopleCount = 100; + const responseCount = 100; + render(); + + expect(screen.getByText("common.limits_reached")).toBeInTheDocument(); + }); + + test("closes the banner when the close button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const closeButton = screen.getByRole("button", { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + await user.click(closeButton); + + expect(screen.queryByText("common.limits_reached")).not.toBeInTheDocument(); + }); + + test("does not render when limits are undefined", () => { + const orgWithoutLimits: TOrganization = { + ...mockOrganization, + billing: { + ...mockOrganization.billing, + limits: { + monthly: { + responses: null, + miu: null, + }, + projects: 1, + }, + }, + }; + + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/web/modules/ui/components/load-segment-modal/index.test.tsx b/apps/web/modules/ui/components/load-segment-modal/index.test.tsx new file mode 100644 index 0000000000..0256e5fb06 --- /dev/null +++ b/apps/web/modules/ui/components/load-segment-modal/index.test.tsx @@ -0,0 +1,243 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { LoadSegmentModal } from "."; + +// Mock the nested SegmentDetail component +vi.mock("lucide-react", async () => { + const actual = await vi.importActual("lucide-react"); + return { + ...actual, + Loader2: vi.fn(() =>
Loader
), + UsersIcon: vi.fn(() =>
Users
), + }; +}); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +describe("LoadSegmentModal", () => { + const mockDate = new Date("2023-01-01T12:00:00Z"); + + const mockCurrentSegment: TSegment = { + id: "current-segment-id", + title: "Current Segment", + description: "Current segment description", + isPrivate: false, + filters: [], + environmentId: "env-1", + surveys: ["survey-1"], + createdAt: mockDate, + updatedAt: mockDate, + }; + + const mockSegments: TSegment[] = [ + { + id: "segment-1", + title: "Segment 1", + description: "Test segment 1", + isPrivate: false, + filters: [], + environmentId: "env-1", + surveys: ["survey-1"], + createdAt: new Date("2023-01-02T12:00:00Z"), + updatedAt: new Date("2023-01-05T12:00:00Z"), + }, + { + id: "segment-2", + title: "Segment 2", + description: "Test segment 2", + isPrivate: false, + filters: [], + environmentId: "env-1", + surveys: ["survey-1"], + createdAt: new Date("2023-02-02T12:00:00Z"), + updatedAt: new Date("2023-02-05T12:00:00Z"), + }, + { + id: "segment-3", + title: "Segment 3 (Private)", + description: "This is private", + isPrivate: true, + filters: [], + environmentId: "env-1", + surveys: ["survey-1"], + createdAt: mockDate, + updatedAt: mockDate, + }, + ]; + + const mockSurveyId = "survey-1"; + const mockSetOpen = vi.fn(); + const mockSetSegment = vi.fn(); + const mockSetIsSegmentEditorOpen = vi.fn(); + const mockOnSegmentLoad = vi.fn(); + + const defaultProps = { + open: true, + setOpen: mockSetOpen, + surveyId: mockSurveyId, + currentSegment: mockCurrentSegment, + segments: mockSegments, + setSegment: mockSetSegment, + setIsSegmentEditorOpen: mockSetIsSegmentEditorOpen, + onSegmentLoad: mockOnSegmentLoad, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders empty state when no segments are available", () => { + render(); + + expect( + screen.getByText("environments.surveys.edit.you_have_not_created_a_segment_yet") + ).toBeInTheDocument(); + expect(screen.queryByText("common.segment")).not.toBeInTheDocument(); + }); + + test("renders segments list correctly when segments are available", () => { + render(); + + // Headers + expect(screen.getByText("common.segment")).toBeInTheDocument(); + expect(screen.getByText("common.updated_at")).toBeInTheDocument(); + expect(screen.getByText("common.created_at")).toBeInTheDocument(); + + // Only non-private segments should be visible (2 out of 3) + expect(screen.getByText("Segment 1")).toBeInTheDocument(); + expect(screen.getByText("Segment 2")).toBeInTheDocument(); + expect(screen.queryByText("Segment 3 (Private)")).not.toBeInTheDocument(); + }); + + test("clicking on a segment loads it and closes the modal", async () => { + mockOnSegmentLoad.mockResolvedValueOnce({ + id: "survey-1", + segment: { + id: "segment-1", + title: "Segment 1", + description: "Test segment 1", + isPrivate: false, + filters: [], + environmentId: "env-1", + surveys: ["survey-1"], + }, + } as unknown as TSurvey); + + const user = userEvent.setup(); + + render(); + + // Find and click the first segment + const segmentElements = screen.getAllByText(/Segment \d/); + await user.click(segmentElements[0]); + + // Wait for the segment to be loaded + await waitFor(() => { + expect(mockOnSegmentLoad).toHaveBeenCalledWith(mockSurveyId, "segment-1"); + expect(mockSetSegment).toHaveBeenCalled(); + }); + }); + + test("displays loading indicator while loading a segment", async () => { + // Mock a delayed resolution to see the loading state + mockOnSegmentLoad.mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: "survey-1", + segment: { + id: "segment-1", + title: "Segment 1", + description: "Test segment 1", + isPrivate: false, + filters: [], + environmentId: "env-1", + surveys: ["survey-1"], + }, + } as unknown as TSurvey); + }, 100); + }) + ); + + const user = userEvent.setup(); + + render(); + + // Find and click the first segment + const segmentElements = screen.getAllByText(/Segment \d/); + await user.click(segmentElements[0]); + + // Check for loader + expect(screen.getByTestId("loader-icon")).toBeInTheDocument(); + }); + + test("shows error toast when segment loading fails", async () => { + mockOnSegmentLoad.mockRejectedValueOnce(new Error("Failed to load segment")); + + const user = userEvent.setup(); + + render(); + + // Find and click the first segment + const segmentElements = screen.getAllByText(/Segment \d/); + await user.click(segmentElements[0]); + + // Wait for the error toast + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + // The toast error is mocked, so we're just verifying the modal closes + }); + }); + + test("doesn't attempt to load a segment if it's the current one", async () => { + const currentSegmentProps = { + ...defaultProps, + segments: [mockCurrentSegment], // Only the current segment is available + }; + + const user = userEvent.setup(); + + render(); + + // Click the current segment + await user.click(screen.getByText("Current Segment")); + + // onSegmentLoad shouldn't be called since we're already using this segment + expect(mockOnSegmentLoad).not.toHaveBeenCalled(); + }); + + test("handles invalid segment data gracefully", async () => { + // Mock an incomplete response from onSegmentLoad + mockOnSegmentLoad.mockResolvedValueOnce({ + // Missing id or segment properties + } as unknown as TSurvey); + + const user = userEvent.setup(); + + render(); + + // Find and click the first segment + const segmentElements = screen.getAllByText(/Segment \d/); + await user.click(segmentElements[0]); + + // Wait for error handling + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/apps/web/modules/ui/components/loading-spinner/index.test.tsx b/apps/web/modules/ui/components/loading-spinner/index.test.tsx new file mode 100644 index 0000000000..f3b4d54c3b --- /dev/null +++ b/apps/web/modules/ui/components/loading-spinner/index.test.tsx @@ -0,0 +1,64 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { LoadingSpinner } from "."; + +describe("LoadingSpinner", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default className", () => { + render(); + + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg?.classList.contains("h-6")).toBe(true); + expect(svg?.classList.contains("w-6")).toBe(true); + expect(svg?.classList.contains("m-2")).toBe(true); + expect(svg?.classList.contains("animate-spin")).toBe(true); + expect(svg?.classList.contains("text-slate-700")).toBe(true); + }); + + test("renders with custom className", () => { + render(); + + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg?.classList.contains("h-10")).toBe(true); + expect(svg?.classList.contains("w-10")).toBe(true); + expect(svg?.classList.contains("m-2")).toBe(true); + expect(svg?.classList.contains("animate-spin")).toBe(true); + expect(svg?.classList.contains("text-slate-700")).toBe(true); + }); + + test("renders with correct SVG structure", () => { + render(); + + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + + // Check that SVG has correct attributes + expect(svg?.getAttribute("xmlns")).toBe("http://www.w3.org/2000/svg"); + expect(svg?.getAttribute("fill")).toBe("none"); + expect(svg?.getAttribute("viewBox")).toBe("0 0 24 24"); + + // Check that SVG contains circle and path elements + const circle = svg?.querySelector("circle"); + const path = svg?.querySelector("path"); + + expect(circle).toBeInTheDocument(); + expect(path).toBeInTheDocument(); + + // Check circle attributes + expect(circle?.getAttribute("cx")).toBe("12"); + expect(circle?.getAttribute("cy")).toBe("12"); + expect(circle?.getAttribute("r")).toBe("10"); + expect(circle?.getAttribute("stroke")).toBe("currentColor"); + expect(circle?.classList.contains("opacity-25")).toBe(true); + + // Check path attributes + expect(path?.getAttribute("fill")).toBe("currentColor"); + expect(path?.classList.contains("opacity-75")).toBe(true); + }); +}); diff --git a/apps/web/modules/ui/components/logo/index.test.tsx b/apps/web/modules/ui/components/logo/index.test.tsx new file mode 100644 index 0000000000..cae4bb4dc2 --- /dev/null +++ b/apps/web/modules/ui/components/logo/index.test.tsx @@ -0,0 +1,40 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { Logo } from "."; + +describe("Logo", () => { + afterEach(() => { + cleanup(); + }); + + test("renders correctly", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 697 150"); + expect(svg).toHaveAttribute("fill", "none"); + expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg"); + }); + + test("accepts and passes through props", () => { + const testClassName = "test-class"; + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("class", testClassName); + }); + + test("contains expected svg elements", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg?.querySelectorAll("path").length).toBeGreaterThan(0); + expect(svg?.querySelector("line")).toBeInTheDocument(); + expect(svg?.querySelectorAll("mask").length).toBe(2); + expect(svg?.querySelectorAll("filter").length).toBe(3); + expect(svg?.querySelectorAll("linearGradient").length).toBe(6); + }); +}); diff --git a/apps/web/modules/ui/components/media-background/index.test.tsx b/apps/web/modules/ui/components/media-background/index.test.tsx new file mode 100644 index 0000000000..6c61ef36bc --- /dev/null +++ b/apps/web/modules/ui/components/media-background/index.test.tsx @@ -0,0 +1,211 @@ +import { SurveyType } from "@prisma/client"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TProjectStyling } from "@formbricks/types/project"; +import { TSurveyStyling } from "@formbricks/types/surveys/types"; +import { MediaBackground } from "."; + +// Mock dependencies +vi.mock("next/image", () => ({ + default: ({ src, alt, onLoadingComplete }: any) => { + // Call onLoadingComplete to simulate image load + if (onLoadingComplete) setTimeout(() => onLoadingComplete(), 0); + return {alt}; + }, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children }: any) => {children}, +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("MediaBackground", () => { + const defaultProps = { + styling: { + background: { + bgType: "color", + bg: "#ffffff", + brightness: 100, + }, + } as TProjectStyling, + surveyType: "app" as SurveyType, + children:
Test Content
, + }; + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("renders with color background", () => { + render(); + + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + const backgroundDiv = document.querySelector(".absolute.inset-0"); + expect(backgroundDiv).toHaveStyle("background-color: #ffffff"); + }); + + test("renders with image background", () => { + const props = { + ...defaultProps, + styling: { + background: { + bgType: "image", + bg: "/test-image.jpg", + brightness: 90, + }, + } as TProjectStyling, + }; + + render(); + + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + expect(screen.getByTestId("next-image")).toHaveAttribute("src", "/test-image.jpg"); + }); + + test("renders with Unsplash image background with author attribution", () => { + const unsplashImageUrl = + "https://unsplash.com/photos/test?authorName=John%20Doe&authorLink=https://unsplash.com/@johndoe"; + const props = { + ...defaultProps, + styling: { + background: { + bgType: "image", + bg: unsplashImageUrl, + brightness: 100, + }, + } as TProjectStyling, + }; + + render(); + + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + expect(screen.getByTestId("next-image")).toHaveAttribute("src", unsplashImageUrl); + expect(screen.getByText("common.photo_by")).toBeInTheDocument(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + test("renders with upload background", () => { + const props = { + ...defaultProps, + styling: { + background: { + bgType: "upload", + bg: "/uploads/test-image.jpg", + brightness: 100, + }, + } as TProjectStyling, + }; + + render(); + + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + expect(screen.getByTestId("next-image")).toHaveAttribute("src", "/uploads/test-image.jpg"); + }); + + test("renders error message when image not found", () => { + const props = { + ...defaultProps, + styling: { + background: { + bgType: "image", + bg: "", + brightness: 100, + }, + } as TProjectStyling, + }; + + render(); + + expect(screen.getByText("common.no_background_image_found")).toBeInTheDocument(); + }); + + test("renders mobile preview", () => { + const props = { + ...defaultProps, + isMobilePreview: true, + }; + + render(); + + const mobileContainer = document.querySelector(".w-\\[22rem\\]"); + expect(mobileContainer).toBeInTheDocument(); + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + }); + + test("renders editor view", () => { + const props = { + ...defaultProps, + isEditorView: true, + }; + + render(); + + const editorContainer = document.querySelector(".rounded-b-lg"); + expect(editorContainer).toBeInTheDocument(); + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + }); + + test("calls onBackgroundLoaded when background is loaded", () => { + const onBackgroundLoaded = vi.fn(); + const props = { + ...defaultProps, + onBackgroundLoaded, + }; + + render(); + + // For color backgrounds, it should be called immediately + expect(onBackgroundLoaded).toHaveBeenCalledWith(true); + }); + + test("renders animation background", () => { + // Mock HTMLMediaElement.prototype methods + Object.defineProperty(window.HTMLMediaElement.prototype, "muted", { + set: vi.fn(), + configurable: true, + }); + + const props = { + ...defaultProps, + styling: { + background: { + bgType: "animation", + bg: "/test-animation.mp4", + brightness: 100, + }, + } as TProjectStyling, + }; + + render(); + + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + const videoElement = document.querySelector("video"); + expect(videoElement).toBeInTheDocument(); + expect(videoElement?.querySelector("source")).toHaveAttribute("src", "/test-animation.mp4"); + }); + + test("applies correct brightness filter", () => { + const props = { + ...defaultProps, + styling: { + background: { + bgType: "color", + bg: "#ffffff", + brightness: 80, + }, + } as TSurveyStyling, + }; + + render(); + + const backgroundDiv = document.querySelector(".absolute.inset-0"); + expect(backgroundDiv).toHaveStyle("filter: brightness(80%)"); + }); +}); diff --git a/apps/web/modules/ui/components/modal-with-tabs/index.test.tsx b/apps/web/modules/ui/components/modal-with-tabs/index.test.tsx new file mode 100644 index 0000000000..1d6c885843 --- /dev/null +++ b/apps/web/modules/ui/components/modal-with-tabs/index.test.tsx @@ -0,0 +1,159 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Modal } from "../modal"; +import { ModalWithTabs } from "./index"; + +// Mock Modal component +vi.mock("../modal", () => ({ + Modal: vi.fn(({ children, open, setOpen, closeOnOutsideClick, size, restrictOverflow, noPadding }) => + open ? ( +
+ {children} + +
+ ) : null + ), +})); + +describe("ModalWithTabs", () => { + afterEach(() => { + cleanup(); + }); + + const mockTabs = [ + { + title: "Tab 1", + children:
Content for Tab 1
, + }, + { + title: "Tab 2", + children:
Content for Tab 2
, + }, + { + title: "Tab 3", + children:
Content for Tab 3
, + }, + ]; + + const defaultProps = { + open: true, + setOpen: vi.fn(), + tabs: mockTabs, + label: "Test Label", + description: "Test Description", + }; + + test("renders modal with tabs when open", () => { + render(); + + expect(screen.getByTestId("modal-component")).toBeInTheDocument(); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + + // Check all tab titles are displayed + expect(screen.getByText("Tab 1")).toBeInTheDocument(); + expect(screen.getByText("Tab 2")).toBeInTheDocument(); + expect(screen.getByText("Tab 3")).toBeInTheDocument(); + + // First tab should be displayed by default + expect(screen.getByTestId("tab-1-content")).toBeInTheDocument(); + expect(screen.queryByTestId("tab-2-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("tab-3-content")).not.toBeInTheDocument(); + }); + + test("doesn't render when not open", () => { + render(); + + expect(screen.queryByTestId("modal-component")).not.toBeInTheDocument(); + }); + + test("switches tabs when clicking on tab buttons", async () => { + const user = userEvent.setup(); + render(); + + // First tab should be active by default + expect(screen.getByTestId("tab-1-content")).toBeInTheDocument(); + + // Click on second tab + await user.click(screen.getByText("Tab 2")); + + // Second tab content should be displayed + expect(screen.queryByTestId("tab-1-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("tab-2-content")).toBeInTheDocument(); + expect(screen.queryByTestId("tab-3-content")).not.toBeInTheDocument(); + + // Click on third tab + await user.click(screen.getByText("Tab 3")); + + // Third tab content should be displayed + expect(screen.queryByTestId("tab-1-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("tab-2-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("tab-3-content")).toBeInTheDocument(); + }); + + test("resets to first tab when reopened", async () => { + const setOpen = vi.fn(); + const { rerender } = render(); + + const user = userEvent.setup(); + + // Switch to second tab + await user.click(screen.getByText("Tab 2")); + expect(screen.getByTestId("tab-2-content")).toBeInTheDocument(); + + // Close the modal + await user.click(screen.getByTestId("close-modal")); + expect(setOpen).toHaveBeenCalledWith(false); + + // Reopen the modal + rerender(); + rerender(); + + // First tab should be active again + expect(screen.getByTestId("tab-1-content")).toBeInTheDocument(); + expect(screen.queryByTestId("tab-2-content")).not.toBeInTheDocument(); + }); + + test("renders with icon when provided", () => { + const mockIcon =
Icon
; + render(); + + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + test("passes proper props to Modal component", () => { + render( + + ); + + const modalElement = screen.getByTestId("modal-component"); + expect(modalElement).toHaveAttribute("data-no-padding", "true"); + expect(modalElement).toHaveAttribute("data-size", "md"); + expect(modalElement).toHaveAttribute("data-restrict-overflow", "true"); + expect(modalElement).toHaveAttribute("data-close-outside", "true"); + }); + + test("uses default values for optional props", () => { + render(); + + const modalElement = screen.getByTestId("modal-component"); + expect(modalElement).toHaveAttribute("data-size", "lg"); + expect(modalElement).toHaveAttribute("data-restrict-overflow", "false"); + }); +}); diff --git a/apps/web/modules/ui/components/modal/index.test.tsx b/apps/web/modules/ui/components/modal/index.test.tsx new file mode 100644 index 0000000000..1d7d5ee550 --- /dev/null +++ b/apps/web/modules/ui/components/modal/index.test.tsx @@ -0,0 +1,192 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Modal } from "."; + +// Mock the Dialog components from radix-ui +vi.mock("@radix-ui/react-dialog", async () => { + const actual = await vi.importActual("@radix-ui/react-dialog"); + return { + ...actual, + Root: ({ children, open, onOpenChange }: any) => ( +
+ {open && children} + +
+ ), + Portal: ({ children }: any) =>
{children}
, + Overlay: ({ className, ...props }: any) => ( +
+ ), + Content: ({ className, children, ...props }: any) => ( +
+ {children} +
+ ), + Close: ({ className, children }: any) => ( + + ), + DialogTitle: ({ children }: any) =>
{children}
, + DialogDescription: () =>
, + }; +}); + +describe("Modal", () => { + afterEach(() => { + cleanup(); + }); + + test("renders nothing when open is false", () => { + render( + {}}> +
Test Content
+
+ ); + + expect(screen.queryByTestId("dialog-root")).not.toBeInTheDocument(); + }); + + test("renders modal content when open is true", () => { + render( + {}}> +
Test Content
+
+ ); + + expect(screen.getByTestId("dialog-root")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-portal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-content")).toBeInTheDocument(); + expect(screen.getByTestId("modal-content")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + test("renders with title when provided", () => { + render( + {}} title="Test Title"> +
Test Content
+
+ ); + + expect(screen.getByTestId("dialog-title")).toBeInTheDocument(); + expect(screen.getByText("Test Title")).toBeInTheDocument(); + }); + + test("applies size classes correctly", () => { + const { rerender } = render( + {}} size="lg"> +
Test Content
+
+ ); + + let content = screen.getByTestId("dialog-content"); + expect(content.className).toContain("sm:max-w-[820px]"); + + rerender( + {}} size="xl"> +
Test Content
+
+ ); + + content = screen.getByTestId("dialog-content"); + expect(content.className).toContain("sm:max-w-[960px]"); + expect(content.className).toContain("sm:max-h-[640px]"); + + rerender( + {}} size="xxl"> +
Test Content
+
+ ); + + content = screen.getByTestId("dialog-content"); + expect(content.className).toContain("sm:max-w-[1240px]"); + expect(content.className).toContain("sm:max-h-[760px]"); + }); + + test("applies noPadding class when noPadding is true", () => { + render( + {}} noPadding> +
Test Content
+
+ ); + + const content = screen.getByTestId("dialog-content"); + expect(content.className).not.toContain("px-4 pt-5 pb-4 sm:p-6"); + }); + + test("applies the blur class to overlay when blur is true", () => { + render( + {}} blur={true}> +
Test Content
+
+ ); + + const overlay = screen.getByTestId("dialog-overlay"); + expect(overlay.className).toContain("backdrop-blur-md"); + }); + + test("does not apply the blur class to overlay when blur is false", () => { + render( + {}} blur={false}> +
Test Content
+
+ ); + + const overlay = screen.getByTestId("dialog-overlay"); + expect(overlay.className).not.toContain("backdrop-blur-md"); + }); + + test("hides close button when hideCloseButton is true", () => { + render( + {}} hideCloseButton={true}> +
Test Content
+
+ ); + + const closeButton = screen.getByTestId("dialog-close"); + expect(closeButton.className).toContain("!hidden"); + }); + + test("calls setOpen when dialog is closed", async () => { + const setOpen = vi.fn(); + const user = userEvent.setup(); + + render( + +
Test Content
+
+ ); + + await user.click(screen.getByTestId("mock-close-trigger")); + expect(setOpen).toHaveBeenCalledWith(false); + }); + + test("applies restrictOverflow class when restrictOverflow is true", () => { + render( + {}} restrictOverflow={true}> +
Test Content
+
+ ); + + const content = screen.getByTestId("dialog-content"); + expect(content.className).not.toContain("overflow-hidden"); + }); + + test("applies custom className when provided", () => { + const customClass = "test-custom-class"; + + render( + {}} className={customClass}> +
Test Content
+
+ ); + + const content = screen.getByTestId("dialog-content"); + expect(content.className).toContain(customClass); + }); +}); diff --git a/apps/web/modules/ui/components/multi-select/badge.test.tsx b/apps/web/modules/ui/components/multi-select/badge.test.tsx new file mode 100644 index 0000000000..1445923fcf --- /dev/null +++ b/apps/web/modules/ui/components/multi-select/badge.test.tsx @@ -0,0 +1,67 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { Badge, badgeVariants } from "./badge"; + +describe("Badge", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default variant", () => { + const { container } = render(Test Badge); + const badgeElement = container.firstChild as HTMLElement; + + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement.textContent).toBe("Test Badge"); + expect(badgeElement.className).toContain("bg-primary"); + expect(badgeElement.className).toContain("border-transparent"); + expect(badgeElement.className).toContain("text-primary-foreground"); + }); + + test("renders with secondary variant", () => { + const { container } = render(Secondary Badge); + const badgeElement = container.firstChild as HTMLElement; + + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement.textContent).toBe("Secondary Badge"); + expect(badgeElement.className).toContain("bg-secondary"); + expect(badgeElement.className).toContain("text-secondary-foreground"); + }); + + test("renders with destructive variant", () => { + const { container } = render(Destructive Badge); + const badgeElement = container.firstChild as HTMLElement; + + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement.textContent).toBe("Destructive Badge"); + expect(badgeElement.className).toContain("bg-destructive"); + expect(badgeElement.className).toContain("text-destructive-foreground"); + }); + + test("renders with outline variant", () => { + const { container } = render(Outline Badge); + const badgeElement = container.firstChild as HTMLElement; + + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement.textContent).toBe("Outline Badge"); + expect(badgeElement.className).toContain("text-foreground"); + }); + + test("accepts additional className", () => { + const { container } = render(Custom Badge); + const badgeElement = container.firstChild as HTMLElement; + + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement.className).toContain("custom-class"); + expect(badgeElement.className).toContain("bg-primary"); // Default variant still applies + }); + + test("passes additional props", () => { + const { container } = render(Props Test); + const badgeElement = container.firstChild as HTMLElement; + + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement).toHaveAttribute("data-testid", "test-badge"); + }); +}); diff --git a/apps/web/modules/ui/components/multi-select/index.test.tsx b/apps/web/modules/ui/components/multi-select/index.test.tsx new file mode 100644 index 0000000000..7a7c37bc68 --- /dev/null +++ b/apps/web/modules/ui/components/multi-select/index.test.tsx @@ -0,0 +1,218 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { MultiSelect } from "./index"; + +// Mock cmdk library +vi.mock("cmdk", () => { + const CommandInput = vi.fn(({ onValueChange, placeholder, disabled, onBlur, onFocus, value }: any) => ( + onValueChange?.(e.target.value)} + onBlur={onBlur} + onFocus={onFocus} + /> + )); + + const Command = Object.assign( + vi.fn(({ children, onKeyDown }: any) => ( +
+ {children} +
+ )), + { Input: CommandInput } + ); + + return { Command }; +}); + +// Mock the Badge component +vi.mock("@/modules/ui/components/multi-select/badge", () => ({ + Badge: ({ children, className }: any) => ( +
+ {children} +
+ ), +})); + +// Mock the Command components +vi.mock("@/modules/ui/components/command", () => ({ + Command: ({ children, className, onKeyDown }: any) => ( +
+ {children} +
+ ), + CommandGroup: ({ children, className }: any) => ( +
+ {children} +
+ ), + CommandItem: ({ children, className, onSelect, onMouseDown }: any) => ( +
onSelect?.()} + onMouseDown={onMouseDown}> + {children} +
+ ), + CommandList: ({ children }: any) =>
{children}
, +})); + +describe("MultiSelect", () => { + afterEach(() => { + cleanup(); + }); + + const options = [ + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana" }, + { value: "orange", label: "Orange" }, + ]; + + test("renders with default props", () => { + render(); + + const input = screen.getByTestId("cmdk-input"); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("placeholder", "Select options..."); + }); + + test("renders with custom placeholder", () => { + render(); + + const input = screen.getByTestId("cmdk-input"); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("placeholder", "Custom placeholder"); + }); + + test("renders with preselected values", () => { + render(); + + const badges = screen.getAllByTestId("badge"); + expect(badges).toHaveLength(2); + expect(badges[0].textContent).toContain("Apple"); + expect(badges[1].textContent).toContain("Banana"); + }); + + test("renders in disabled state", () => { + render(); + + const command = screen.getByTestId("command"); + expect(command.className).toContain("opacity-50"); + expect(command.className).toContain("cursor-not-allowed"); + + const input = screen.getByTestId("cmdk-input"); + expect(input).toBeDisabled(); + }); + + test("shows options list on input focus", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId("cmdk-input"); + await user.click(input); + + // Simulate focus event + input.dispatchEvent(new FocusEvent("focus")); + + // After focus, the command list should be present which contains command items + const commandList = screen.getByTestId("command-list"); + expect(commandList).toBeInTheDocument(); + + // Test that the commandList contains at least one command item + const commandGroup = within(commandList).getByTestId("command-group"); + expect(commandGroup).toBeInTheDocument(); + }); + + test("filters options based on input text", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + const input = screen.getByTestId("cmdk-input"); + await user.click(input); + input.dispatchEvent(new FocusEvent("focus")); + + // Mock the filtered state by rerendering with a specific input value + // This simulates what happens when a user types "app" + rerender(); + + // Manually trigger the display of filtered options + const commandList = screen.getByTestId("command-list"); + const commandGroup = within(commandList).getByTestId("command-group"); + const appleOption = within(commandGroup).getByText("Apple"); + + expect(appleOption).toBeInTheDocument(); + }); + + test("selects an option on click", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("cmdk-input"); + await user.click(input); + input.dispatchEvent(new FocusEvent("focus")); + + const appleOption = screen.getAllByTestId("command-item")[0]; + await user.click(appleOption); + + expect(onChange).toHaveBeenCalled(); + }); + + test("unselects an option when X button is clicked", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + // Find all badges + const badges = screen.getAllByTestId("badge"); + expect(badges).toHaveLength(2); + + // Find the X buttons (they are children of the badges) + const xButtons = screen.getAllByRole("button"); + expect(xButtons).toHaveLength(2); + + // Click the first X button + await user.click(xButtons[0]); + + expect(onChange).toHaveBeenCalled(); + }); + + test("doesn't show already selected options in dropdown", async () => { + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("cmdk-input"); + await user.click(input); + input.dispatchEvent(new FocusEvent("focus")); + + // Should only show non-selected options + const optionItems = screen.getAllByTestId("command-item"); + expect(optionItems).toHaveLength(2); + expect(optionItems[0].textContent).toBe("Banana"); + expect(optionItems[1].textContent).toBe("Orange"); + }); + + test("updates when value prop changes", () => { + const { rerender } = render(); + + let badges = screen.getAllByTestId("badge"); + expect(badges).toHaveLength(1); + expect(badges[0].textContent).toContain("Apple"); + + rerender(); + + badges = screen.getAllByTestId("badge"); + expect(badges).toHaveLength(2); + expect(badges[0].textContent).toContain("Apple"); + expect(badges[1].textContent).toContain("Banana"); + }); +}); diff --git a/apps/web/modules/ui/components/no-code-action-form/components/css-selector.test.tsx b/apps/web/modules/ui/components/no-code-action-form/components/css-selector.test.tsx new file mode 100644 index 0000000000..5a7b223462 --- /dev/null +++ b/apps/web/modules/ui/components/no-code-action-form/components/css-selector.test.tsx @@ -0,0 +1,138 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClassInput } from "@formbricks/types/action-classes"; +import { CssSelector } from "./css-selector"; + +// Mock the AdvancedOptionToggle component +vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({ + AdvancedOptionToggle: ({ children, isChecked, onToggle, title, disabled, htmlId }: any) => { + // Store a reference to onToggle so we can actually toggle state when the button is clicked + const handleToggle = () => onToggle(!isChecked); + + return ( +
+
{title}
+ + {isChecked &&
{children}
} +
+ ); + }, +})); + +// Mock the Input component +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ disabled, placeholder, onChange, value, isInvalid }: any) => ( + onChange && onChange(e)} + data-invalid={isInvalid} + /> + ), +})); + +// Mock the form components +vi.mock("@/modules/ui/components/form", () => ({ + FormControl: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormField: ({ render, name }: any) => + render({ + field: { + value: undefined, + onChange: vi.fn(), + name, + }, + fieldState: { error: null }, + }), + FormItem: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +// Mock the tolgee translation +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Helper component for the form +const TestWrapper = ({ cssSelector, disabled = false }: { cssSelector?: string; disabled?: boolean }) => { + const form = useForm({ + defaultValues: { + name: "Test Action", + description: "Test Description", + noCodeConfig: { + type: "click", + elementSelector: { + cssSelector, + }, + }, + }, + }); + + // Override the watch function to simulate the state change + form.watch = vi.fn().mockImplementation((name) => { + if (name === "noCodeConfig.elementSelector.cssSelector") { + return cssSelector; + } + return undefined; + }); + + return ; +}; + +describe("CssSelector", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with cssSelector undefined", () => { + render(); + + expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("toggle-title")).toHaveTextContent("environments.actions.css_selector"); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "false"); + expect(screen.queryByTestId("toggle-content")).not.toBeInTheDocument(); + }); + + test("renders with cssSelector defined", () => { + render(); + + expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "true"); + expect(screen.getByTestId("toggle-content")).toBeInTheDocument(); + expect(screen.getByTestId("css-input")).toBeInTheDocument(); + }); + + test("disables the component when disabled prop is true", () => { + render(); + + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-disabled", "true"); + }); + + test("toggle opens and closes the input field", async () => { + const user = userEvent.setup(); + // Start with cssSelector undefined to have the toggle closed initially + const { rerender } = render(); + + const toggleButton = screen.getByTestId("toggle-button-CssSelector"); + + // Initially closed + expect(screen.queryByTestId("toggle-content")).not.toBeInTheDocument(); + + // Open it - simulate change through rerender + await user.click(toggleButton); + rerender(); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "true"); + + // Close it again + await user.click(toggleButton); + rerender(); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "false"); + }); +}); diff --git a/apps/web/modules/ui/components/no-code-action-form/components/inner-html-selector.test.tsx b/apps/web/modules/ui/components/no-code-action-form/components/inner-html-selector.test.tsx new file mode 100644 index 0000000000..db11cf1d4e --- /dev/null +++ b/apps/web/modules/ui/components/no-code-action-form/components/inner-html-selector.test.tsx @@ -0,0 +1,138 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClassInput } from "@formbricks/types/action-classes"; +import { InnerHtmlSelector } from "./inner-html-selector"; + +// Mock the AdvancedOptionToggle component +vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({ + AdvancedOptionToggle: ({ children, isChecked, onToggle, title, disabled, htmlId }: any) => { + // Store a reference to onToggle so we can actually toggle state when the button is clicked + const handleToggle = () => onToggle(!isChecked); + + return ( +
+
{title}
+ + {isChecked &&
{children}
} +
+ ); + }, +})); + +// Mock the Input component +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ disabled, placeholder, onChange, value, isInvalid }: any) => ( + onChange && onChange(e)} + data-invalid={isInvalid} + /> + ), +})); + +// Mock the form components +vi.mock("@/modules/ui/components/form", () => ({ + FormControl: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormField: ({ render, name }: any) => + render({ + field: { + value: undefined, + onChange: vi.fn(), + name, + }, + fieldState: { error: null }, + }), + FormItem: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +// Mock the tolgee translation +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Helper component for the form +const TestWrapper = ({ innerHtml, disabled = false }: { innerHtml?: string; disabled?: boolean }) => { + const form = useForm({ + defaultValues: { + name: "Test Action", + description: "Test Description", + noCodeConfig: { + type: "click", + elementSelector: { + innerHtml, + }, + }, + }, + }); + + // Override the watch function to simulate the state change + form.watch = vi.fn().mockImplementation((name) => { + if (name === "noCodeConfig.elementSelector.innerHtml") { + return innerHtml; + } + return undefined; + }); + + return ; +}; + +describe("InnerHtmlSelector", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with innerHtml undefined", () => { + render(); + + expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("toggle-title")).toHaveTextContent("environments.actions.inner_text"); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "false"); + expect(screen.queryByTestId("toggle-content")).not.toBeInTheDocument(); + }); + + test("renders with innerHtml defined", () => { + render(); + + expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "true"); + expect(screen.getByTestId("toggle-content")).toBeInTheDocument(); + expect(screen.getByTestId("innerhtml-input")).toBeInTheDocument(); + }); + + test("disables the component when disabled prop is true", () => { + render(); + + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-disabled", "true"); + }); + + test("toggle opens and closes the input field", async () => { + const user = userEvent.setup(); + // Start with innerHtml undefined to have the toggle closed initially + const { rerender } = render(); + + const toggleButton = screen.getByTestId("toggle-button-InnerText"); + + // Initially closed + expect(screen.queryByTestId("toggle-content")).not.toBeInTheDocument(); + + // Open it - simulate change through rerender + await user.click(toggleButton); + rerender(); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "true"); + + // Close it again + await user.click(toggleButton); + rerender(); + expect(screen.getByTestId("advanced-option-toggle")).toHaveAttribute("data-checked", "false"); + }); +}); diff --git a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx new file mode 100644 index 0000000000..23e9de93a3 --- /dev/null +++ b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx @@ -0,0 +1,253 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClassInput } from "@formbricks/types/action-classes"; +import { PageUrlSelector } from "./page-url-selector"; + +// Mock testURLmatch function +vi.mock("@/lib/utils/url", () => ({ + testURLmatch: vi.fn((testUrl, value, rule) => { + // Simple mock implementation + if (rule === "exactMatch" && testUrl === value) return "yes"; + if (rule === "contains" && testUrl.includes(value)) return "yes"; + if (rule === "startsWith" && testUrl.startsWith(value)) return "yes"; + if (rule === "endsWith" && testUrl.endsWith(value)) return "yes"; + if (rule === "notMatch" && testUrl !== value) return "yes"; + if (rule === "notContains" && !testUrl.includes(value)) return "yes"; + return "no"; + }), +})); + +// Mock toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock the TabToggle component +vi.mock("@/modules/ui/components/tab-toggle", () => ({ + TabToggle: ({ options, onChange, defaultSelected, id, disabled }: any) => ( +
+ {options.map((option: any) => ( + + ))} +
+ ), +})); + +// Mock the Input component +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ + className, + type, + disabled, + placeholder, + onChange, + value, + isInvalid, + name, + autoComplete, + ...rest + }: any) => ( + onChange && onChange(e)} + data-invalid={isInvalid} + autoComplete={autoComplete} + {...rest} + /> + ), +})); + +// Mock the Button component - Fixed to use correct data-testid values +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, variant, size, onClick, disabled, className, type }: any) => ( + + ), +})); + +// Mock the Select component +vi.mock("@/modules/ui/components/select", () => ({ + Select: ({ children, onValueChange, value, name, disabled }: any) => ( +
+ {children} +
+ ), + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children, className }: any) => ( +
+ {children} +
+ ), + SelectValue: ({ placeholder }: any) =>
{placeholder}
, +})); + +// Mock the Label component +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ children, className }: any) => ( + + ), +})); + +// Mock icons +vi.mock("lucide-react", () => ({ + PlusIcon: () =>
, + TrashIcon: () =>
, +})); + +// Mock the form components +vi.mock("@/modules/ui/components/form", () => ({ + FormControl: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormField: ({ render, control, name }: any) => + render({ + field: { + onChange: vi.fn(), + value: (() => { + if (name === "noCodeConfig.urlFilters") { + return control?._formValues?.noCodeConfig?.urlFilters || []; + } + if (name?.startsWith("noCodeConfig.urlFilters.")) { + const parts = name.split("."); + const index = parseInt(parts[2]); + const property = parts[3]; + return control?._formValues?.noCodeConfig?.urlFilters?.[index]?.[property] || ""; + } + return ""; + })(), + name, + }, + fieldState: { error: null }, + }), + FormItem: ({ children, className }: any) =>
{children}
, +})); + +// Mock the tolgee translation +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Helper component for the form +const TestWrapper = ({ + urlFilters = [] as { + rule: "startsWith" | "exactMatch" | "contains" | "endsWith" | "notMatch" | "notContains"; + value: string; + }[], + isReadOnly = false, +}) => { + const form = useForm({ + defaultValues: { + name: "Test Action", + description: "Test Description", + noCodeConfig: { + type: "click", + urlFilters, + }, + }, + }); + + return ; +}; + +describe("PageUrlSelector", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default values and 'all' filter type", () => { + render(); + + expect(screen.getByTestId("form-label")).toBeInTheDocument(); + expect(screen.getByText("environments.actions.page_filter")).toBeInTheDocument(); + expect(screen.getByTestId("tab-toggle-filter")).toBeInTheDocument(); + expect(screen.getByTestId("tab-option-all")).toHaveAttribute("data-selected", "true"); + expect(screen.queryByTestId("button-add-url")).not.toBeInTheDocument(); + }); + + test("renders with 'specific' filter type", () => { + render(); + + expect(screen.getByTestId("tab-option-specific")).toHaveAttribute("data-selected", "true"); + expect(screen.getByTestId("select-noCodeConfig.urlFilters.0.rule")).toBeInTheDocument(); + expect(screen.getByTestId("button-add-url")).toBeInTheDocument(); + expect(screen.getByTestId("input-noCodeConfig.urlFilters.testUrl")).toBeInTheDocument(); + }); + + test("disables components when isReadOnly is true", () => { + render( + + ); + + expect(screen.getByTestId("tab-toggle-filter")).toHaveAttribute("data-disabled", "true"); + expect(screen.getByTestId("button-add-url")).toHaveAttribute("disabled", ""); + }); + + test("shows multiple URL filters", () => { + const urlFilters = [ + { rule: "exactMatch" as const, value: "https://example.com" }, + { rule: "contains" as const, value: "pricing" }, + ]; + + render(); + + expect(screen.getByTestId("select-noCodeConfig.urlFilters.0.rule")).toBeInTheDocument(); + expect(screen.getByTestId("select-noCodeConfig.urlFilters.1.rule")).toBeInTheDocument(); + // Check that we have a "trash" button for each rule (since there are multiple) + const trashIcons = screen.getAllByTestId("trash-icon"); + expect(trashIcons.length).toBe(2); + }); + + test("test URL match functionality", async () => { + const testUrl = "https://example.com/pricing"; + const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + // Updated testId to match the actual button's testId from our mock + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // Toast should be called to show match result + const toast = await import("react-hot-toast"); + expect(toast.default.success).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx index aee6eded98..cd2557fcb4 100644 --- a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx +++ b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx @@ -99,7 +99,7 @@ export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => { />
{filterType === "specific" && ( -
+
({ + Alert: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Mock the form components +vi.mock("@/modules/ui/components/form", () => ({ + FormControl: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormField: ({ render, control }: any) => + render({ + field: { + value: control?._formValues?.noCodeConfig?.type || "", + onChange: vi.fn(), + }, + }), + FormItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormError: () => null, +})); + +// Mock the TabToggle component +vi.mock("@/modules/ui/components/tab-toggle", () => ({ + TabToggle: ({ options, onChange, defaultSelected, id, disabled }: any) => ( +
+ {options.map((option: any) => ( + + ))} +
+ ), +})); + +// Mock the Label component +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ children, className }: any) => ( + + ), +})); + +// Mock child components +vi.mock("./components/css-selector", () => ({ + CssSelector: ({ form, disabled }: any) => ( +
+ CSS Selector +
+ ), +})); + +vi.mock("./components/inner-html-selector", () => ({ + InnerHtmlSelector: ({ form, disabled }: any) => ( +
+ Inner HTML Selector +
+ ), +})); + +vi.mock("./components/page-url-selector", () => ({ + PageUrlSelector: ({ form, isReadOnly }: any) => ( +
+ Page URL Selector +
+ ), +})); + +// Mock the tolgee translation +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Helper component for the form +const TestWrapper = ({ + noCodeConfig = { type: "click" }, + isReadOnly = false, +}: { + noCodeConfig?: { type: "click" | "pageView" | "exitIntent" | "fiftyPercentScroll" }; + isReadOnly?: boolean; +}) => { + const form = useForm({ + defaultValues: { + name: "Test Action", + description: "Test Description", + noCodeConfig, + }, + }); + + return ; +}; + +describe("NoCodeActionForm", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the form with click type", () => { + render(); + + expect(screen.getByTestId("tab-toggle-userAction")).toBeInTheDocument(); + expect(screen.getByTestId("tab-option-click")).toHaveAttribute("data-selected", "true"); + expect(screen.getByTestId("css-selector")).toBeInTheDocument(); + expect(screen.getByTestId("inner-html-selector")).toBeInTheDocument(); + expect(screen.getByTestId("page-url-selector")).toBeInTheDocument(); + }); + + test("renders the form with pageView type", () => { + render(); + + expect(screen.getByTestId("tab-option-pageView")).toHaveAttribute("data-selected", "true"); + expect(screen.getByTestId("alert")).toBeInTheDocument(); + expect(screen.getByTestId("alert-title")).toHaveTextContent("environments.actions.page_view"); + }); + + test("renders the form with exitIntent type", () => { + render(); + + expect(screen.getByTestId("tab-option-exitIntent")).toHaveAttribute("data-selected", "true"); + expect(screen.getByTestId("alert")).toBeInTheDocument(); + expect(screen.getByTestId("alert-title")).toHaveTextContent("environments.actions.exit_intent"); + }); + + test("renders the form with fiftyPercentScroll type", () => { + render(); + + expect(screen.getByTestId("tab-option-fiftyPercentScroll")).toHaveAttribute("data-selected", "true"); + expect(screen.getByTestId("alert")).toBeInTheDocument(); + expect(screen.getByTestId("alert-title")).toHaveTextContent("environments.actions.fifty_percent_scroll"); + }); + + test("passes isReadOnly to child components", () => { + render(); + + expect(screen.getByTestId("tab-toggle-userAction")).toBeInTheDocument(); + expect(screen.getByTestId("css-selector")).toHaveAttribute("data-disabled", "true"); + expect(screen.getByTestId("inner-html-selector")).toHaveAttribute("data-disabled", "true"); + expect(screen.getByTestId("page-url-selector")).toHaveAttribute("data-readonly", "true"); + }); +}); diff --git a/apps/web/modules/ui/components/no-mobile-overlay/index.test.tsx b/apps/web/modules/ui/components/no-mobile-overlay/index.test.tsx new file mode 100644 index 0000000000..667b879660 --- /dev/null +++ b/apps/web/modules/ui/components/no-mobile-overlay/index.test.tsx @@ -0,0 +1,38 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { NoMobileOverlay } from "./index"; + +// Mock the tolgee translation +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => + key === "common.mobile_overlay_text" ? "Please use desktop to access this section" : key, + }), +})); + +describe("NoMobileOverlay", () => { + afterEach(() => { + cleanup(); + }); + + test("renders overlay with correct text", () => { + render(); + + expect(screen.getByText("Please use desktop to access this section")).toBeInTheDocument(); + }); + + test("has proper z-index for overlay", () => { + render(); + + const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed"); + expect(overlay).toHaveClass("z-[9999]"); + }); + + test("has responsive layout with sm:hidden class", () => { + render(); + + const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed"); + expect(overlay).toHaveClass("sm:hidden"); + }); +}); diff --git a/apps/web/modules/ui/components/option-card/index.test.tsx b/apps/web/modules/ui/components/option-card/index.test.tsx new file mode 100644 index 0000000000..892f4dad7c --- /dev/null +++ b/apps/web/modules/ui/components/option-card/index.test.tsx @@ -0,0 +1,81 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { OptionCard } from "./index"; + +vi.mock("@/modules/ui/components/loading-spinner", () => ({ + LoadingSpinner: () =>
Loading Spinner
, +})); + +describe("OptionCard", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with small size correctly", () => { + render(); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + + const card = screen.getByRole("button"); + expect(card).toHaveClass("p-4", "rounded-lg", "w-60", "shadow-md"); + }); + + test("renders with medium size correctly", () => { + render(); + + const card = screen.getByRole("button"); + expect(card).toHaveClass("p-6", "rounded-xl", "w-80", "shadow-lg"); + }); + + test("renders with large size correctly", () => { + render(); + + const card = screen.getByRole("button"); + expect(card).toHaveClass("p-8", "rounded-2xl", "w-100", "shadow-xl"); + }); + + test("displays loading spinner when loading is true", () => { + render(); + + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); + + test("does not display loading spinner when loading is false", () => { + render(); + + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + test("calls onSelect when clicked", async () => { + const handleSelect = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole("button")); + expect(handleSelect).toHaveBeenCalledTimes(1); + }); + + test("renders with custom cssId", () => { + render(); + + const card = screen.getByRole("button"); + expect(card).toHaveAttribute("id", "custom-id"); + }); + + test("renders children correctly", () => { + render( + +
Child content
+
+ ); + + expect(screen.getByTestId("child-element")).toBeInTheDocument(); + expect(screen.getByText("Child content")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/options-switch/index.test.tsx b/apps/web/modules/ui/components/options-switch/index.test.tsx new file mode 100644 index 0000000000..313f8db482 --- /dev/null +++ b/apps/web/modules/ui/components/options-switch/index.test.tsx @@ -0,0 +1,92 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { OptionsSwitch } from "./index"; + +describe("OptionsSwitch", () => { + afterEach(() => { + cleanup(); + }); + + const mockOptions = [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3", disabled: true }, + ]; + + test("renders all options correctly", () => { + render( {}} />); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + + test("highlights the current option", () => { + render( {}} />); + + // Check that the highlight div exists + const highlight = document.querySelector(".absolute.bottom-1.top-1.rounded-md.bg-slate-100"); + expect(highlight).toBeInTheDocument(); + }); + + test("calls handleOptionChange with option value when clicked", async () => { + const handleOptionChange = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByText("Option 2")); + + expect(handleOptionChange).toHaveBeenCalledWith("option2"); + }); + + test("does not call handleOptionChange when disabled option is clicked", async () => { + const handleOptionChange = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByText("Option 3")); + + expect(handleOptionChange).not.toHaveBeenCalled(); + }); + + test("renders icons when provided", () => { + const optionsWithIcons = [ + { + value: "option1", + label: "Option 1", + icon: , + }, + { + value: "option2", + label: "Option 2", + }, + ]; + + render( + {}} /> + ); + + expect(screen.getByTestId("icon-option1")).toBeInTheDocument(); + }); + + test("updates highlight position when current option changes", () => { + const { rerender } = render( + {}} /> + ); + + // Re-render with different current option + rerender( {}} />); + + // The highlight style should be updated through useEffect + // We can verify the component doesn't crash on re-render + expect(screen.getByText("Option 2")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/otp-input/index.test.tsx b/apps/web/modules/ui/components/otp-input/index.test.tsx new file mode 100644 index 0000000000..f087bd058a --- /dev/null +++ b/apps/web/modules/ui/components/otp-input/index.test.tsx @@ -0,0 +1,119 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { OTPInput } from "./index"; + +describe("OTPInput", () => { + afterEach(() => { + cleanup(); + }); + + test("renders correct number of input fields", () => { + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + expect(inputs).toHaveLength(6); + }); + + test("displays provided value correctly", () => { + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + expect(inputs[0]).toHaveValue("1"); + expect(inputs[1]).toHaveValue("2"); + expect(inputs[2]).toHaveValue("3"); + expect(inputs[3]).toHaveValue("4"); + expect(inputs[4]).toHaveValue("5"); + expect(inputs[5]).toHaveValue("6"); + }); + + test("applies custom container class", () => { + const onChange = vi.fn(); + render( + + ); + + const container = screen.getAllByRole("textbox")[0].parentElement; + expect(container).toHaveClass("test-container-class"); + }); + + test("applies custom input box class", () => { + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + inputs.forEach((input) => { + expect(input).toHaveClass("test-input-class"); + }); + }); + + test("disables inputs when disabled prop is true", () => { + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + }); + + test("calls onChange with updated value when input changes", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + await user.click(inputs[3]); + await user.keyboard("4"); + + expect(onChange).toHaveBeenCalledWith("1234"); + }); + + test("only accepts digit inputs", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + await user.click(inputs[0]); + await user.keyboard("a"); + + expect(onChange).not.toHaveBeenCalled(); + }); + + test("moves focus to next input after entering a digit", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + await user.click(inputs[0]); + await user.keyboard("1"); + + expect(document.activeElement).toBe(inputs[1]); + }); + + test("navigates inputs with arrow keys", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const inputs = screen.getAllByRole("textbox"); + await user.click(inputs[1]); // Focus on the 2nd input + + await user.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe(inputs[2]); + + await user.keyboard("{ArrowLeft}"); + expect(document.activeElement).toBe(inputs[1]); + + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(inputs[2]); + + await user.keyboard("{ArrowUp}"); + expect(document.activeElement).toBe(inputs[1]); + }); +}); diff --git a/apps/web/modules/ui/components/page-content-wrapper/index.test.tsx b/apps/web/modules/ui/components/page-content-wrapper/index.test.tsx new file mode 100644 index 0000000000..7d931edb9b --- /dev/null +++ b/apps/web/modules/ui/components/page-content-wrapper/index.test.tsx @@ -0,0 +1,48 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { PageContentWrapper } from "./index"; + +describe("PageContentWrapper", () => { + afterEach(() => { + cleanup(); + }); + + test("renders children correctly", () => { + const { getByText } = render( + +
Test Content
+
+ ); + + expect(getByText("Test Content")).toBeInTheDocument(); + }); + + test("applies default classes", () => { + const { container } = render( + +
Test Content
+
+ ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("h-full"); + expect(wrapper).toHaveClass("space-y-6"); + expect(wrapper).toHaveClass("p-6"); + }); + + test("applies additional className when provided", () => { + const { container } = render( + +
Test Content
+
+ ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("h-full"); + expect(wrapper).toHaveClass("space-y-6"); + expect(wrapper).toHaveClass("p-6"); + expect(wrapper).toHaveClass("bg-gray-100"); + expect(wrapper).toHaveClass("rounded-lg"); + }); +}); diff --git a/apps/web/modules/ui/components/page-header/index.test.tsx b/apps/web/modules/ui/components/page-header/index.test.tsx new file mode 100644 index 0000000000..a5dc2a1248 --- /dev/null +++ b/apps/web/modules/ui/components/page-header/index.test.tsx @@ -0,0 +1,58 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { PageHeader } from "./index"; + +describe("PageHeader", () => { + afterEach(() => { + cleanup(); + }); + + test("renders page title correctly", () => { + render(); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Dashboard")).toHaveClass("text-3xl font-bold text-slate-800 capitalize"); + }); + + test("renders with CTA", () => { + render(Add User} />); + + expect(screen.getByText("Users")).toBeInTheDocument(); + expect(screen.getByTestId("cta-button")).toBeInTheDocument(); + expect(screen.getByText("Add User")).toBeInTheDocument(); + }); + + test("renders children correctly", () => { + render( + +
Additional content
+
+ ); + + expect(screen.getByText("Settings")).toBeInTheDocument(); + expect(screen.getByTestId("child-element")).toBeInTheDocument(); + expect(screen.getByText("Additional content")).toBeInTheDocument(); + }); + + test("renders with both CTA and children", () => { + render( + New Product}> +
Product filters
+
+ ); + + expect(screen.getByText("Products")).toBeInTheDocument(); + expect(screen.getByTestId("cta-button")).toBeInTheDocument(); + expect(screen.getByText("New Product")).toBeInTheDocument(); + expect(screen.getByTestId("child-element")).toBeInTheDocument(); + expect(screen.getByText("Product filters")).toBeInTheDocument(); + }); + + test("has border-b class", () => { + const { container } = render(); + const headerElement = container.firstChild as HTMLElement; + + expect(headerElement).toHaveClass("border-b"); + expect(headerElement).toHaveClass("border-slate-200"); + }); +}); diff --git a/apps/web/modules/ui/components/password-input/index.test.tsx b/apps/web/modules/ui/components/password-input/index.test.tsx new file mode 100644 index 0000000000..253f3607b1 --- /dev/null +++ b/apps/web/modules/ui/components/password-input/index.test.tsx @@ -0,0 +1,83 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test } from "vitest"; +import { PasswordInput } from "./index"; + +describe("PasswordInput", () => { + afterEach(() => { + cleanup(); + }); + + test("renders password input with type password by default", () => { + render(); + + const input = screen.getByPlaceholderText("Enter password"); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("type", "password"); + }); + + test("toggles password visibility when eye icon is clicked", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText("Enter password"); + expect(input).toHaveAttribute("type", "password"); + + // Find and click the toggle button (eye icon) + const toggleButton = screen.getByRole("button"); + await user.click(toggleButton); + + // Check if input type changed to text + expect(input).toHaveAttribute("type", "text"); + + // Click the toggle button again + await user.click(toggleButton); + + // Check if input type changed back to password + expect(input).toHaveAttribute("type", "password"); + }); + + test("applies custom className to input", () => { + render(); + + const input = screen.getByPlaceholderText("Enter password"); + expect(input).toHaveClass("custom-input-class"); + }); + + test("applies custom containerClassName", () => { + render(); + + const container = screen.getByPlaceholderText("Enter password").parentElement; + expect(container).toHaveClass("custom-container-class"); + }); + + test("passes through other HTML input attributes", () => { + render( + + ); + + const input = screen.getByPlaceholderText("Enter password"); + expect(input).toHaveAttribute("id", "password-field"); + expect(input).toHaveAttribute("name", "password"); + expect(input).toHaveAttribute("required"); + expect(input).toBeDisabled(); + }); + + test("displays EyeIcon when password is hidden", () => { + render(); + + const eyeIcon = document.querySelector("svg"); + expect(eyeIcon).toBeInTheDocument(); + + // This is a simple check for the presence of the icon + // We can't easily test the exact Lucide icon type in this setup + }); + + test("toggle button is of type button to prevent form submission", () => { + render(); + + const toggleButton = screen.getByRole("button"); + expect(toggleButton).toHaveAttribute("type", "button"); + }); +}); diff --git a/apps/web/modules/ui/components/pending-downgrade-banner/index.test.tsx b/apps/web/modules/ui/components/pending-downgrade-banner/index.test.tsx new file mode 100644 index 0000000000..4512c4dbb6 --- /dev/null +++ b/apps/web/modules/ui/components/pending-downgrade-banner/index.test.tsx @@ -0,0 +1,118 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { PendingDowngradeBanner } from "./index"; + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string, params?: any) => { + if (key === "common.pending_downgrade") return "Pending Downgrade"; + if (key === "common.we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable") + return "We were unable to verify your license because the license server is unreachable"; + if (key === "common.you_will_be_downgraded_to_the_community_edition_on_date") + return `You will be downgraded to the community edition on ${params?.date}`; + if (key === "common.you_are_downgraded_to_the_community_edition") + return "You are downgraded to the community edition"; + if (key === "common.learn_more") return "Learn more"; + if (key === "common.close") return "Close"; + return key; + }, + }), +})); + +// Mock next/link +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +describe("PendingDowngradeBanner", () => { + afterEach(() => { + cleanup(); + }); + + test("renders banner when active and isPendingDowngrade are true", () => { + const currentDate = new Date(); + const lastChecked = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000); // One day ago + + render( + + ); + + expect(screen.getByText("Pending Downgrade")).toBeInTheDocument(); + // Check if learn more link is present + const learnMoreLink = screen.getByText("Learn more"); + expect(learnMoreLink).toBeInTheDocument(); + expect(screen.getByTestId("mock-link")).toHaveAttribute( + "href", + "/environments/env-123/settings/enterprise" + ); + }); + + test("doesn't render when active is false", () => { + const currentDate = new Date(); + const lastChecked = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000); // One day ago + + render( + + ); + + expect(screen.queryByText("Pending Downgrade")).not.toBeInTheDocument(); + }); + + test("doesn't render when isPendingDowngrade is false", () => { + const currentDate = new Date(); + const lastChecked = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000); // One day ago + + render( + + ); + + expect(screen.queryByText("Pending Downgrade")).not.toBeInTheDocument(); + }); + + test("closes banner when close button is clicked", async () => { + const user = userEvent.setup(); + const currentDate = new Date(); + const lastChecked = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000); // One day ago + + render( + + ); + + expect(screen.getByText("Pending Downgrade")).toBeInTheDocument(); + + // Find and click the close button + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + // Banner should no longer be visible + expect(screen.queryByText("Pending Downgrade")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/picture-selection-response/index.test.tsx b/apps/web/modules/ui/components/picture-selection-response/index.test.tsx new file mode 100644 index 0000000000..cf1de432f1 --- /dev/null +++ b/apps/web/modules/ui/components/picture-selection-response/index.test.tsx @@ -0,0 +1,77 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { PictureSelectionResponse } from "./index"; + +// Mock next/image because it's not available in the test environment +vi.mock("next/image", () => ({ + __esModule: true, + default: ({ src, alt, className }: { src: string; alt: string; className: string }) => ( + {alt} + ), +})); + +describe("PictureSelectionResponse", () => { + afterEach(() => { + cleanup(); + }); + + const mockChoices = [ + { + id: "choice1", + imageUrl: "https://example.com/image1.jpg", + }, + { + id: "choice2", + imageUrl: "https://example.com/image2.jpg", + }, + { + id: "choice3", + imageUrl: "https://example.com/image3.jpg", + }, + ]; + + test("renders images for selected choices", () => { + const { container } = render( + + ); + + const images = container.querySelectorAll("img"); + expect(images).toHaveLength(2); + expect(images[0]).toHaveAttribute("src", "https://example.com/image1.jpg"); + expect(images[1]).toHaveAttribute("src", "https://example.com/image3.jpg"); + }); + + test("renders nothing when selected is not an array", () => { + // @ts-ignore - Testing invalid prop type + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("handles expanded layout", () => { + const { container } = render( + + ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("flex-wrap"); + }); + + test("handles non-expanded layout", () => { + const { container } = render( + + ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).not.toHaveClass("flex-wrap"); + }); + + test("handles choices not in the mapping", () => { + const { container } = render( + + ); + + const images = container.querySelectorAll("img"); + expect(images).toHaveLength(1); // Only one valid image should be rendered + }); +}); diff --git a/apps/web/modules/ui/components/popover/index.test.tsx b/apps/web/modules/ui/components/popover/index.test.tsx new file mode 100644 index 0000000000..0b76e6d186 --- /dev/null +++ b/apps/web/modules/ui/components/popover/index.test.tsx @@ -0,0 +1,112 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Popover, PopoverContent, PopoverTrigger } from "./index"; + +// Mock RadixUI's Portal to make testing easier +vi.mock("@radix-ui/react-popover", async () => { + const actual = await vi.importActual("@radix-ui/react-popover"); + return { + ...actual, + Portal: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +describe("Popover", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the popover with trigger and content", async () => { + const user = userEvent.setup(); + + render( + + Open Popover + Popover Content + + ); + + // Trigger should be visible + const trigger = screen.getByText("Open Popover"); + expect(trigger).toBeInTheDocument(); + + // Content should not be visible initially + expect(screen.queryByText("Popover Content")).not.toBeInTheDocument(); + + // Click the trigger to open the popover + await user.click(trigger); + + // Content should now be visible inside the Portal + const portal = screen.getByTestId("portal"); + expect(portal).toBeInTheDocument(); + expect(portal).toHaveTextContent("Popover Content"); + }); + + test("passes align and sideOffset props to popover content", async () => { + const user = userEvent.setup(); + + render( + + Open Popover + + Popover Content + + + ); + + // Click the trigger to open the popover + await user.click(screen.getByText("Open Popover")); + + // Content should have the align and sideOffset props + const content = screen.getByTestId("portal").firstChild as HTMLElement; + + // These attributes are handled by RadixUI internally, so we can't directly test the DOM + // but we can verify the component doesn't crash when these props are provided + expect(content).toBeInTheDocument(); + expect(content).toHaveTextContent("Popover Content"); + }); + + test("forwards ref to popover content", async () => { + const user = userEvent.setup(); + const ref = vi.fn(); + + render( + + Open Popover + Popover Content + + ); + + // Click the trigger to open the popover + await user.click(screen.getByText("Open Popover")); + + // Ref should have been called - this test is mostly to ensure the component supports refs + expect(screen.getByTestId("portal")).toBeInTheDocument(); + }); + + test("closes when clicking outside", async () => { + const user = userEvent.setup(); + + render( + <> +
Outside
+ + Open Popover + Popover Content + + + ); + + // Open the popover + await user.click(screen.getByText("Open Popover")); + expect(screen.getByTestId("portal")).toBeInTheDocument(); + + // Click outside + await user.click(screen.getByTestId("outside-element")); + + // This test is more about ensuring the component has the default behavior of closing on outside click + // The actual closing is handled by RadixUI, so we can't directly test it without more complex mocking + }); +}); diff --git a/apps/web/modules/ui/components/preview-survey/components/tab-option.test.tsx b/apps/web/modules/ui/components/preview-survey/components/tab-option.test.tsx new file mode 100644 index 0000000000..f53db2b9a1 --- /dev/null +++ b/apps/web/modules/ui/components/preview-survey/components/tab-option.test.tsx @@ -0,0 +1,61 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TabOption } from "./tab-option"; + +describe("TabOption", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with active state", () => { + render(Icon} onClick={() => {}} />); + + const tabElement = screen.getByTestId("test-icon").parentElement; + expect(tabElement).toBeInTheDocument(); + expect(tabElement).toHaveClass("rounded-full"); + expect(tabElement).toHaveClass("bg-slate-200"); + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + test("renders with inactive state", () => { + render(Icon} onClick={() => {}} />); + + const tabElement = screen.getByTestId("test-icon").parentElement; + expect(tabElement).toBeInTheDocument(); + expect(tabElement).not.toHaveClass("rounded-full"); + expect(tabElement).not.toHaveClass("bg-slate-200"); + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + test("calls onClick handler when clicked", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render( + Icon} onClick={handleClick} /> + ); + + const tabElement = screen.getByTestId("test-icon").parentElement; + await user.click(tabElement!); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test("renders children (icon) properly", () => { + render( + + Nested Icon +
+ } + onClick={() => {}} + /> + ); + + expect(screen.getByTestId("complex-icon")).toBeInTheDocument(); + expect(screen.getByText("Nested Icon")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/preview-survey/index.test.tsx b/apps/web/modules/ui/components/preview-survey/index.test.tsx new file mode 100644 index 0000000000..7491c95546 --- /dev/null +++ b/apps/web/modules/ui/components/preview-survey/index.test.tsx @@ -0,0 +1,356 @@ +import { SurveyType } from "@prisma/client"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { PreviewSurvey } from "./index"; + +// Mock dependent components +vi.mock("@/modules/ui/components/client-logo", () => ({ + ClientLogo: ({ environmentId, projectLogo, previewSurvey }: any) => ( +
+ {projectLogo ? "Custom Logo" : "Default Logo"} +
+ ), +})); + +vi.mock("@/modules/ui/components/media-background", () => ({ + MediaBackground: ({ children, surveyType, styling, isMobilePreview, isEditorView }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/reset-progress-button", () => ({ + ResetProgressButton: ({ onClick }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/survey", () => ({ + SurveyInline: ({ + survey, + isBrandingEnabled, + isPreviewMode, + getSetQuestionId, + onClose, + onFinished, + languageCode, + }: any) => { + // Store the setQuestionId function to be used in tests + if (getSetQuestionId) { + getSetQuestionId((val: string) => { + // Just a simple implementation for testing + }); + } + + return ( +
+ + +
+ ); + }, +})); + +vi.mock("./components/modal", () => ({ + Modal: ({ children, isOpen, placement, darkOverlay, clickOutsideClose, previewMode }: any) => + isOpen ? ( +
+ {children} +
+ ) : null, +})); + +vi.mock("./components/tab-option", () => ({ + TabOption: ({ active, onClick, icon }: any) => ( + + ), +})); + +// Mock framer-motion to avoid animation issues in tests +vi.mock("framer-motion", () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, + Variants: vi.fn(), +})); + +// Mock the icon components +vi.mock("lucide-react", () => ({ + ExpandIcon: () => Expand, + ShrinkIcon: () => Shrink, + MonitorIcon: () => Monitor, + SmartphoneIcon: () => Smartphone, +})); + +// Mock the tolgee translation +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("PreviewSurvey", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + const mockProject = { + id: "project-1", + name: "Test Project", + placement: "bottomRight", + darkOverlay: false, + clickOutsideClose: true, + styling: { + roundness: 8, + allowStyleOverwrite: false, + cardBackgroundColor: { + light: "#FFFFFF", + }, + highlightBorderColor: { + light: "", + }, + isLogoHidden: false, + }, + inAppSurveyBranding: true, + linkSurveyBranding: true, + logo: null, + } as any; + + const mockEnvironment = { + id: "env-1", + appSetupCompleted: true, + } as any; + + const mockSurvey = { + id: "survey-1", + name: "Test Survey", + type: "app" as SurveyType, + welcomeCard: { + enabled: true, + }, + questions: [ + { id: "q1", headline: "Question 1" }, + { id: "q2", headline: "Question 2" }, + ], + endings: [], + styling: { + overwriteThemeStyling: false, + roundness: 8, + cardBackgroundColor: { + light: "#FFFFFF", + }, + highlightBorderColor: { + light: "", + }, + isLogoHidden: false, + }, + recaptcha: { + enabled: false, + }, + } as any; + + test("renders desktop preview mode by default", () => { + render( + + ); + + expect(screen.getByText("environments.surveys.edit.your_web_app")).toBeInTheDocument(); + expect(screen.getByTestId("survey-modal")).toBeInTheDocument(); + expect(screen.getByTestId("survey-inline")).toBeInTheDocument(); + expect(screen.getByTestId("tab-option-active")).toBeInTheDocument(); + expect(screen.getByTestId("tab-option-inactive")).toBeInTheDocument(); + }); + + test("switches to mobile preview mode when clicked", async () => { + const user = userEvent.setup(); + render( + + ); + + // Initially in desktop mode + expect(screen.getByTestId("survey-modal")).toHaveAttribute("data-preview-mode", "desktop"); + + // Click on mobile tab + const mobileTab = screen.getAllByTestId(/tab-option/)[0]; + await user.click(mobileTab); + + // Should be in mobile preview mode now + expect(screen.getByText("Preview")).toBeInTheDocument(); + expect(screen.getByTestId("media-background")).toHaveAttribute("data-is-mobile-preview", "true"); + }); + + test("resets survey progress when reset button is clicked", async () => { + // Add the modal component to the DOM even after click + + const user = userEvent.setup(); + + render( + + ); + + const resetButton = screen.getByTestId("reset-progress-button"); + await user.click(resetButton); + + // Wait for component to update + await vi.waitFor(() => { + expect(screen.queryByTestId("survey-inline")).toBeInTheDocument(); + }); + }); + + test("handles survey completion", async () => { + // Add the modal component to the DOM even after click + + const user = userEvent.setup(); + + render( + + ); + + // Find and click the finish button + const finishButton = screen.getByTestId("finish-survey"); + await user.click(finishButton); + + // Wait for component to update + await new Promise((r) => setTimeout(r, 600)); + + // Verify we can find survey elements after completion + expect(screen.queryByTestId("survey-inline")).toBeInTheDocument(); + }); + + test("renders fullwidth preview when specified", () => { + render( + + ); + + // Should render with MediaBackground in desktop mode + expect(screen.queryByTestId("survey-modal")).not.toBeInTheDocument(); + expect(screen.getByTestId("media-background")).toBeInTheDocument(); + expect(screen.getByTestId("media-background")).toHaveAttribute("data-is-editor-view", "true"); + }); + + test("handles expand/shrink preview", async () => { + const user = userEvent.setup(); + + // Override the Lucide-react mock for this specific test + vi.mock("lucide-react", () => { + let isExpanded = false; + + return { + ExpandIcon: () => ( + { + isExpanded = true; + }}> + Expand + + ), + ShrinkIcon: () => Shrink, + MonitorIcon: () => Monitor, + SmartphoneIcon: () => Smartphone, + }; + }); + + render( + + ); + + // Initially shows expand icon + expect(screen.getByTestId("expand-icon")).toBeInTheDocument(); + + // Since we can't easily test the full expand/shrink functionality in the test environment, + // we'll skip verifying the shrink icon and just make sure the component doesn't crash + }); + + test("renders with reCAPTCHA enabled when specified", () => { + const surveyWithRecaptcha = { + ...mockSurvey, + recaptcha: { + enabled: true, + }, + }; + + render( + + ); + + // Should render with isSpamProtectionEnabled=true + expect(screen.getByTestId("survey-inline")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/preview-survey/index.tsx b/apps/web/modules/ui/components/preview-survey/index.tsx index 5898c76763..5e232d1a87 100644 --- a/apps/web/modules/ui/components/preview-survey/index.tsx +++ b/apps/web/modules/ui/components/preview-survey/index.tsx @@ -246,10 +246,10 @@ export const PreviewSurvey = ({ className="relative flex h-full w-[95%] items-center justify-center rounded-lg border border-slate-300 bg-slate-200"> {previewMode === "mobile" && ( <> -

+

Preview

-
+
) : (
-
+
{!styling.isLogoHidden && ( )} @@ -392,7 +392,7 @@ export const PreviewSurvey = ({ styling={styling} ContentRef={ContentRef as React.RefObject} isEditorView> -
+
{!styling.isLogoHidden && ( )} diff --git a/apps/web/modules/ui/components/preview-survey/lib/utils.test.ts b/apps/web/modules/ui/components/preview-survey/lib/utils.test.ts new file mode 100644 index 0000000000..6892c34a98 --- /dev/null +++ b/apps/web/modules/ui/components/preview-survey/lib/utils.test.ts @@ -0,0 +1,36 @@ +import "@testing-library/jest-dom/vitest"; +import { describe, expect, test } from "vitest"; +import { getPlacementStyle } from "./utils"; + +describe("getPlacementStyle", () => { + test("returns correct style for bottomRight placement", () => { + const style = getPlacementStyle("bottomRight"); + expect(style).toBe("bottom-3 sm:right-3"); + }); + + test("returns correct style for topRight placement", () => { + const style = getPlacementStyle("topRight"); + expect(style).toBe("sm:top-6 sm:right-6"); + }); + + test("returns correct style for topLeft placement", () => { + const style = getPlacementStyle("topLeft"); + expect(style).toBe("sm:top-6 sm:left-6"); + }); + + test("returns correct style for bottomLeft placement", () => { + const style = getPlacementStyle("bottomLeft"); + expect(style).toBe("bottom-3 sm:left-3"); + }); + + test("returns correct style for center placement", () => { + const style = getPlacementStyle("center"); + expect(style).toBe("top-1/2 left-1/2 transform !-translate-x-1/2 -translate-y-1/2"); + }); + + test("returns default style for invalid placement", () => { + // @ts-ignore - Testing with invalid input + const style = getPlacementStyle("invalidPlacement"); + expect(style).toBe("bottom-3 sm:right-3"); + }); +}); diff --git a/apps/web/modules/ui/components/pro-badge/index.test.tsx b/apps/web/modules/ui/components/pro-badge/index.test.tsx new file mode 100644 index 0000000000..e15e891c7c --- /dev/null +++ b/apps/web/modules/ui/components/pro-badge/index.test.tsx @@ -0,0 +1,50 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ProBadge } from "./index"; + +// Mock lucide-react's CrownIcon +vi.mock("lucide-react", () => ({ + CrownIcon: () =>
, +})); + +describe("ProBadge", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the badge with correct elements", () => { + render(); + + // Check for container with correct classes + const badgeContainer = screen.getByText("PRO").closest("div"); + expect(badgeContainer).toBeInTheDocument(); + expect(badgeContainer).toHaveClass("ml-2"); + expect(badgeContainer).toHaveClass("flex"); + expect(badgeContainer).toHaveClass("items-center"); + expect(badgeContainer).toHaveClass("justify-center"); + expect(badgeContainer).toHaveClass("rounded-lg"); + expect(badgeContainer).toHaveClass("border"); + expect(badgeContainer).toHaveClass("border-slate-200"); + expect(badgeContainer).toHaveClass("bg-slate-100"); + expect(badgeContainer).toHaveClass("p-0.5"); + expect(badgeContainer).toHaveClass("text-slate-500"); + }); + + test("contains crown icon", () => { + render(); + + const crownIcon = screen.getByTestId("crown-icon"); + expect(crownIcon).toBeInTheDocument(); + }); + + test("displays PRO text", () => { + render(); + + const proText = screen.getByText("PRO"); + expect(proText).toBeInTheDocument(); + expect(proText.tagName.toLowerCase()).toBe("span"); + expect(proText).toHaveClass("ml-1"); + expect(proText).toHaveClass("text-xs"); + }); +}); diff --git a/apps/web/modules/ui/components/question-toggle-table/index.test.tsx b/apps/web/modules/ui/components/question-toggle-table/index.test.tsx new file mode 100644 index 0000000000..6d5522f689 --- /dev/null +++ b/apps/web/modules/ui/components/question-toggle-table/index.test.tsx @@ -0,0 +1,322 @@ +import "@testing-library/jest-dom/vitest"; +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 } from "@formbricks/types/surveys/types"; +import { QuestionToggleTable } from "./index"; + +// Mock the Switch component +vi.mock("@/modules/ui/components/switch", () => ({ + Switch: ({ checked, onCheckedChange, disabled }: any) => ( + + ), +})); + +// Mock the QuestionFormInput component +vi.mock("@/modules/survey/components/question-form-input", () => ({ + QuestionFormInput: ({ id, value, updateQuestion, questionIdx, selectedLanguageCode }: any) => ( + { + const updatedAttributes: any = {}; + const fieldId = id.split(".")[0]; + const attributeName = id.split(".")[1]; + + updatedAttributes[fieldId] = { + show: true, + required: false, + placeholder: { + [selectedLanguageCode]: e.target.value, + }, + }; + + updateQuestion(questionIdx, updatedAttributes); + }} + /> + ), +})); + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("QuestionToggleTable", () => { + afterEach(() => { + cleanup(); + }); + + const mockFields = [ + { + id: "street", + show: true, + required: true, + label: "Street", + placeholder: { default: "Enter your street" }, + }, + { + id: "city", + show: true, + required: false, + label: "City", + placeholder: { default: "Enter your city" }, + }, + ]; + + const mockSurvey: TSurvey = { + id: "survey-1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "web", + environmentId: "env-1", + status: "draft", + questions: [ + { + id: "question-1", + type: "address", + headline: "Your address", + required: true, + street: { + show: true, + required: true, + placeholder: { default: "Street" }, + }, + city: { + show: true, + required: false, + placeholder: { default: "City" }, + }, + }, + ], + welcomeCard: { + enabled: false, + }, + thankYouCard: { + enabled: false, + }, + displayProgress: false, + progressBar: { + display: false, + }, + styling: {}, + autoComplete: false, + closeOnDate: null, + recaptcha: { + enabled: false, + }, + } as unknown as TSurvey; + + test("renders address fields correctly", () => { + const updateQuestionMock = vi.fn(); + + render( + {}} + locale={"en-US"} + /> + ); + + // Check table headers + expect(screen.getByText("environments.surveys.edit.address_fields")).toBeInTheDocument(); + expect(screen.getByText("common.show")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.edit.required")).toBeInTheDocument(); + expect(screen.getByText("common.label")).toBeInTheDocument(); + + // Check field labels + expect(screen.getByText("Street")).toBeInTheDocument(); + expect(screen.getByText("City")).toBeInTheDocument(); + + // Check switches are rendered with correct state + const streetShowSwitch = screen.getAllByTestId("switch-on")[0]; + const streetRequiredSwitch = screen.getAllByTestId("switch-on")[1]; + const cityShowSwitch = screen.getAllByTestId("switch-on")[2]; + const cityRequiredSwitch = screen.getByTestId("switch-off"); + + expect(streetShowSwitch).toBeInTheDocument(); + expect(streetRequiredSwitch).toBeInTheDocument(); + expect(cityShowSwitch).toBeInTheDocument(); + expect(cityRequiredSwitch).toBeInTheDocument(); + + // Check inputs are rendered + expect(screen.getByTestId("input-street.placeholder")).toBeInTheDocument(); + expect(screen.getByTestId("input-city.placeholder")).toBeInTheDocument(); + }); + + test("renders contact fields correctly", () => { + const updateQuestionMock = vi.fn(); + + render( + {}} + locale={"en-US"} + /> + ); + + expect(screen.getByText("environments.surveys.edit.contact_fields")).toBeInTheDocument(); + }); + + test("handles show toggle", async () => { + const updateQuestionMock = vi.fn(); + const user = userEvent.setup(); + + render( + {}} + locale={"en-US"} + /> + ); + + // Toggle the city show switch + const cityShowSwitch = screen.getAllByTestId("switch-on")[2]; + await user.click(cityShowSwitch); + + // Check that updateQuestion was called with correct parameters + expect(updateQuestionMock).toHaveBeenCalledWith(0, { + city: { + show: false, + required: false, + placeholder: { default: "Enter your city" }, + }, + }); + }); + + test("handles required toggle", async () => { + const updateQuestionMock = vi.fn(); + const user = userEvent.setup(); + + render( + {}} + locale={"en-US"} + /> + ); + + // Toggle the city required switch + const cityRequiredSwitch = screen.getByTestId("switch-off"); + await user.click(cityRequiredSwitch); + + // Check that updateQuestion was called with correct parameters + expect(updateQuestionMock).toHaveBeenCalledWith(0, { + city: { + show: true, + required: true, + placeholder: { default: "Enter your city" }, + }, + }); + }); + + test("disables show toggle when it's the last visible field", async () => { + const fieldsWithOnlyOneVisible = [ + { + id: "street", + show: true, + required: false, + label: "Street", + placeholder: { default: "Enter your street" }, + }, + { + id: "city", + show: false, + required: false, + label: "City", + placeholder: { default: "Enter your city" }, + }, + ]; + + render( + {}} + selectedLanguageCode="default" + setSelectedLanguageCode={() => {}} + locale={"en-US"} + /> + ); + + // The street show toggle should be disabled + const streetShowSwitch = screen.getByTestId("switch-on"); + expect(streetShowSwitch).toHaveAttribute("data-disabled", "true"); + expect(streetShowSwitch).toBeDisabled(); + }); + + test("disables required toggle when field is not shown", async () => { + const fieldsWithHiddenField = [ + { + id: "street", + show: true, + required: false, + label: "Street", + placeholder: { default: "Enter your street" }, + }, + { + id: "city", + show: false, + required: false, + label: "City", + placeholder: { default: "Enter your city" }, + }, + ]; + + render( + {}} + selectedLanguageCode="default" + setSelectedLanguageCode={() => {}} + locale={"en-US"} + /> + ); + + // The city required toggle should be disabled + const requiredSwitches = screen.getAllByTestId("switch-off"); + const cityRequiredSwitch = requiredSwitches[requiredSwitches.length - 1]; // Last one should be city's required switch + expect(cityRequiredSwitch).toHaveAttribute("data-disabled", "true"); + expect(cityRequiredSwitch).toBeDisabled(); + }); +}); diff --git a/apps/web/modules/ui/components/radio-group/index.test.tsx b/apps/web/modules/ui/components/radio-group/index.test.tsx new file mode 100644 index 0000000000..679dd9408f --- /dev/null +++ b/apps/web/modules/ui/components/radio-group/index.test.tsx @@ -0,0 +1,134 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { RadioGroup, RadioGroupItem } from "./index"; + +describe("RadioGroup", () => { + afterEach(() => { + cleanup(); + }); + + test("renders radio group with items", () => { + render( + +
+ + +
+
+ + +
+
+ ); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByLabelText("Option 1")).toBeInTheDocument(); + expect(screen.getByLabelText("Option 2")).toBeInTheDocument(); + }); + + test("selects default value", () => { + render( + +
+ + +
+
+ + +
+
+ ); + + const option1 = screen.getByLabelText("Option 1"); + const option2 = screen.getByLabelText("Option 2"); + + expect(option1).toBeChecked(); + expect(option2).not.toBeChecked(); + }); + + test("changes selection when clicking on a different option", async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + + render( + +
+ + +
+
+ + +
+
+ ); + + const option2 = screen.getByLabelText("Option 2"); + await user.click(option2); + + expect(handleValueChange).toHaveBeenCalledWith("option2"); + }); + + test("renders disabled radio items", async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + + render( + +
+ + +
+
+ + +
+
+ ); + + const option2 = screen.getByLabelText("Option 2 (Disabled)"); + expect(option2).toBeDisabled(); + + await user.click(option2); + expect(handleValueChange).not.toHaveBeenCalled(); + }); + + test("applies custom className to RadioGroup", () => { + render( + +
+ + +
+
+ ); + + const radioGroup = screen.getByRole("radiogroup"); + expect(radioGroup).toHaveClass("custom-class"); + expect(radioGroup).toHaveClass("grid"); + expect(radioGroup).toHaveClass("gap-x-3"); + }); + + test("applies custom className to RadioGroupItem", () => { + render( + +
+ + +
+
+ ); + + const radioItem = screen.getByLabelText("Option 1"); + expect(radioItem).toHaveClass("custom-item-class"); + expect(radioItem).toHaveClass("h-4"); + expect(radioItem).toHaveClass("w-4"); + expect(radioItem).toHaveClass("rounded-full"); + expect(radioItem).toHaveClass("border"); + expect(radioItem).toHaveClass("border-slate-300"); + }); +}); diff --git a/apps/web/modules/ui/components/ranking-response/index.test.tsx b/apps/web/modules/ui/components/ranking-response/index.test.tsx new file mode 100644 index 0000000000..2da2a20943 --- /dev/null +++ b/apps/web/modules/ui/components/ranking-response/index.test.tsx @@ -0,0 +1,82 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { RankingResponse } from "./index"; + +describe("RankingResponse", () => { + afterEach(() => { + cleanup(); + }); + + test("renders ranked items correctly", () => { + const rankedItems = ["Apple", "Banana", "Cherry"]; + + render(); + + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("#2")).toBeInTheDocument(); + expect(screen.getByText("#3")).toBeInTheDocument(); + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.getByText("Cherry")).toBeInTheDocument(); + }); + + test("applies expanded layout", () => { + const rankedItems = ["Apple", "Banana"]; + + const { container } = render(); + + const parentDiv = container.firstChild; + expect(parentDiv).not.toHaveClass("flex"); + expect(parentDiv).not.toHaveClass("space-x-2"); + }); + + test("applies non-expanded layout", () => { + const rankedItems = ["Apple", "Banana"]; + + const { container } = render(); + + const parentDiv = container.firstChild; + expect(parentDiv).toHaveClass("flex"); + expect(parentDiv).toHaveClass("space-x-2"); + }); + + test("handles empty values", () => { + const rankedItems = ["Apple", "", "Cherry"]; + + render(); + + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("#3")).toBeInTheDocument(); + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Cherry")).toBeInTheDocument(); + expect(screen.queryByText("#2")).not.toBeInTheDocument(); + }); + + test("displays items in the correct order", () => { + const rankedItems = ["First", "Second", "Third"]; + + render(); + + const rankNumbers = screen.getAllByText(/^#\d$/); + const rankItems = screen.getAllByText(/(First|Second|Third)/); + + expect(rankNumbers[0].textContent).toBe("#1"); + expect(rankItems[0].textContent).toBe("First"); + + expect(rankNumbers[1].textContent).toBe("#2"); + expect(rankItems[1].textContent).toBe("Second"); + + expect(rankNumbers[2].textContent).toBe("#3"); + expect(rankItems[2].textContent).toBe("Third"); + }); + + test("renders with RTL support", () => { + const rankedItems = ["תפוח", "בננה", "דובדבן"]; + + const { container } = render(); + + const parentDiv = container.firstChild as HTMLElement; + expect(parentDiv).toHaveAttribute("dir", "auto"); + }); +}); diff --git a/apps/web/modules/ui/components/ranking-response/index.tsx b/apps/web/modules/ui/components/ranking-response/index.tsx index 48cada6082..ac75ad566d 100644 --- a/apps/web/modules/ui/components/ranking-response/index.tsx +++ b/apps/web/modules/ui/components/ranking-response/index.tsx @@ -5,7 +5,7 @@ interface RankingResponseProps { isExpanded: boolean; } -export const RankingRespone = ({ value, isExpanded }: RankingResponseProps) => { +export const RankingResponse = ({ value, isExpanded }: RankingResponseProps) => { return (
{value.map( diff --git a/apps/web/modules/ui/components/rating-response/index.test.tsx b/apps/web/modules/ui/components/rating-response/index.test.tsx new file mode 100644 index 0000000000..ecea358b82 --- /dev/null +++ b/apps/web/modules/ui/components/rating-response/index.test.tsx @@ -0,0 +1,71 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { RatingResponse } from "./index"; + +// Mock the RatingSmiley component +vi.mock("@/modules/analysis/components/RatingSmiley", () => ({ + RatingSmiley: ({ active, idx, range, addColors }: any) => ( +
+ Smiley Rating +
+ ), +})); + +describe("RatingResponse", () => { + afterEach(() => { + cleanup(); + }); + + test("renders null when answer is not a number", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("returns raw answer when scale or range is undefined", () => { + const { container } = render(); + expect(container).toHaveTextContent("3"); + }); + + test("renders smiley rating correctly", () => { + render(); + + const smiley = screen.getByTestId("rating-smiley"); + expect(smiley).toBeInTheDocument(); + expect(smiley).toHaveAttribute("data-active", "false"); + expect(smiley).toHaveAttribute("data-idx", "2"); // 0-based index for rating 3 + expect(smiley).toHaveAttribute("data-range", "5"); + expect(smiley).toHaveAttribute("data-add-colors", "false"); + }); + + test("renders smiley rating with colors", () => { + render(); + + const smiley = screen.getByTestId("rating-smiley"); + expect(smiley).toBeInTheDocument(); + expect(smiley).toHaveAttribute("data-add-colors", "true"); + }); + + test("renders number rating correctly", () => { + const { container } = render(); + expect(container).toHaveTextContent("7"); + }); + + test("handles full rating correctly", () => { + render(); + + const stars = document.querySelectorAll("svg"); + expect(stars).toHaveLength(5); + + // All stars should be filled + for (let i = 0; i < 5; i++) { + expect(stars[i].getAttribute("fill")).toBe("rgb(250 204 21)"); + expect(stars[i]).toHaveClass("text-yellow-400"); + } + }); +}); diff --git a/apps/web/modules/ui/components/reset-progress-button/index.test.tsx b/apps/web/modules/ui/components/reset-progress-button/index.test.tsx new file mode 100644 index 0000000000..0cba5022f3 --- /dev/null +++ b/apps/web/modules/ui/components/reset-progress-button/index.test.tsx @@ -0,0 +1,53 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ResetProgressButton } from "./index"; + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => (key === "common.restart" ? "Restart" : key), + }), +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + Repeat2: () =>
, +})); + +describe("ResetProgressButton", () => { + afterEach(() => { + cleanup(); + }); + + test("renders button with correct text", () => { + render( {}} />); + + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("Restart")).toBeInTheDocument(); + expect(screen.getByTestId("repeat-icon")).toBeInTheDocument(); + }); + + test("button has correct styling", () => { + render( {}} />); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("h-fit"); + expect(button).toHaveClass("bg-white"); + expect(button).toHaveClass("text-slate-500"); + expect(button).toHaveClass("px-2"); + expect(button).toHaveClass("py-0"); + }); + + test("calls onClick handler when clicked", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button")); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/modules/ui/components/response-badges/index.test.tsx b/apps/web/modules/ui/components/response-badges/index.test.tsx new file mode 100644 index 0000000000..d52550c597 --- /dev/null +++ b/apps/web/modules/ui/components/response-badges/index.test.tsx @@ -0,0 +1,68 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { ResponseBadges } from "./index"; + +describe("ResponseBadges", () => { + afterEach(() => { + cleanup(); + }); + + test("renders string items correctly", () => { + const items = ["Apple", "Banana", "Cherry"]; + render(); + + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.getByText("Cherry")).toBeInTheDocument(); + + const badges = screen.getAllByText(/Apple|Banana|Cherry/); + expect(badges).toHaveLength(3); + + badges.forEach((badge) => { + expect(badge.closest("span")).toHaveClass("bg-slate-200"); + expect(badge.closest("span")).toHaveClass("rounded-md"); + expect(badge.closest("span")).toHaveClass("px-2"); + expect(badge.closest("span")).toHaveClass("py-1"); + expect(badge.closest("span")).toHaveClass("font-medium"); + }); + }); + + test("renders number items correctly", () => { + const items = [1, 2, 3]; + render(); + + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + test("applies expanded layout when isExpanded=true", () => { + const items = ["Apple", "Banana", "Cherry"]; + + const { container } = render(); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass("flex-wrap"); + }); + + test("does not apply expanded layout when isExpanded=false", () => { + const items = ["Apple", "Banana", "Cherry"]; + + const { container } = render(); + + const wrapper = container.firstChild; + expect(wrapper).not.toHaveClass("flex-wrap"); + }); + + test("applies default styles correctly", () => { + const items = ["Apple"]; + + const { container } = render(); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass("my-1"); + expect(wrapper).toHaveClass("flex"); + expect(wrapper).toHaveClass("gap-2"); + }); +}); diff --git a/apps/web/modules/ui/components/save-as-new-segment-modal/index.test.tsx b/apps/web/modules/ui/components/save-as-new-segment-modal/index.test.tsx new file mode 100644 index 0000000000..6e68be88a8 --- /dev/null +++ b/apps/web/modules/ui/components/save-as-new-segment-modal/index.test.tsx @@ -0,0 +1,230 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SaveAsNewSegmentModal } from "./index"; + +// Mock react-hook-form +vi.mock("react-hook-form", () => ({ + useForm: () => ({ + register: vi.fn().mockImplementation((name) => ({ + name, + onChange: vi.fn(), + onBlur: vi.fn(), + ref: vi.fn(), + })), + handleSubmit: vi.fn().mockImplementation((fn) => (data) => { + fn(data); + return false; + }), + formState: { errors: {} }, + setValue: vi.fn(), + }), +})); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + UsersIcon: () =>
, +})); + +// Mock Modal component +vi.mock("@/modules/ui/components/modal", () => ({ + Modal: ({ open, setOpen, noPadding, children }) => { + if (!open) return null; + return ( +
+ + {children} +
+ ); + }, +})); + +// Mock Button component +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, variant, onClick, type, loading }) => ( + + ), +})); + +// Mock Input component +vi.mock("@/modules/ui/components/input", () => ({ + Input: (props) => , +})); + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key) => { + const translations = { + "environments.segments.save_as_new_segment": "Save as New Segment", + "environments.segments.save_your_filters_as_a_segment_to_use_it_in_other_surveys": + "Save your filters as a segment to use it in other surveys", + "common.name": "Name", + "environments.segments.ex_power_users": "Ex: Power Users", + "common.description": "Description", + "environments.segments.most_active_users_in_the_last_30_days": + "Most active users in the last 30 days", + "common.cancel": "Cancel", + "common.save": "Save", + "environments.segments.segment_created_successfully": "Segment created successfully", + "environments.segments.segment_updated_successfully": "Segment updated successfully", + }; + return translations[key] || key; + }, + }), +})); + +describe("SaveAsNewSegmentModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockProps = { + open: true, + setOpen: vi.fn(), + localSurvey: { + id: "survey1", + environmentId: "env1", + } as any, + segment: { + id: "segment1", + isPrivate: false, + filters: [{ id: "filter1" }], + } as any, + setSegment: vi.fn(), + setIsSegmentEditorOpen: vi.fn(), + onCreateSegment: vi.fn().mockResolvedValue({ id: "newSegment" }), + onUpdateSegment: vi.fn().mockResolvedValue({ id: "updatedSegment" }), + }; + + test("renders the modal when open is true", () => { + render(); + + expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByText("Save as New Segment")).toBeInTheDocument(); + expect(screen.getByText("Save your filters as a segment to use it in other surveys")).toBeInTheDocument(); + expect(screen.getByTestId("users-icon")).toBeInTheDocument(); + }); + + test("doesn't render when open is false", () => { + render(); + + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + + test("renders form fields correctly", () => { + render(); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByTestId("input-title")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByTestId("input-description")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Save")).toBeInTheDocument(); + }); + + test("calls setOpen with false when close button is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("modal-close")); + + expect(mockProps.setOpen).toHaveBeenCalledWith(false); + }); + + test("calls setOpen with false when cancel button is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Cancel")); + + expect(mockProps.setOpen).toHaveBeenCalledWith(false); + }); + + test("calls onCreateSegment when form is submitted with new segment", async () => { + const user = userEvent.setup(); + const createProps = { + ...mockProps, + segment: { + ...mockProps.segment, + id: "temp", // indicates a new segment + }, + }; + + render(); + + // Submit the form + await user.click(screen.getByText("Save")); + + // Check that onCreateSegment was called + expect(createProps.onCreateSegment).toHaveBeenCalled(); + expect(createProps.setSegment).toHaveBeenCalled(); + expect(createProps.setIsSegmentEditorOpen).toHaveBeenCalledWith(false); + expect(createProps.setOpen).toHaveBeenCalledWith(false); + }); + + test("calls onUpdateSegment when form is submitted with an existing private segment", async () => { + const user = userEvent.setup(); + const updateProps = { + ...mockProps, + segment: { + ...mockProps.segment, + isPrivate: true, + }, + }; + + render(); + + // Submit the form + await user.click(screen.getByText("Save")); + + // Check that onUpdateSegment was called + expect(updateProps.onUpdateSegment).toHaveBeenCalled(); + expect(updateProps.setSegment).toHaveBeenCalled(); + expect(updateProps.setIsSegmentEditorOpen).toHaveBeenCalledWith(false); + expect(updateProps.setOpen).toHaveBeenCalledWith(false); + }); + + test("shows loading state on button during submission", async () => { + // Use a delayed promise to check loading state + const delayedPromise = new Promise((resolve) => { + setTimeout(() => resolve({ id: "newSegment" }), 100); + }); + + const loadingProps = { + ...mockProps, + segment: { + ...mockProps.segment, + id: "temp", + }, + onCreateSegment: vi.fn().mockReturnValue(delayedPromise), + }; + + render(); + + // Submit the form + await userEvent.click(screen.getByText("Save")); + + // Button should show loading state + const saveButton = screen.getByTestId("button-primary"); + expect(saveButton).toHaveAttribute("data-loading", "true"); + }); +}); diff --git a/apps/web/modules/ui/components/search-bar/index.test.tsx b/apps/web/modules/ui/components/search-bar/index.test.tsx new file mode 100644 index 0000000000..3a6bd00b04 --- /dev/null +++ b/apps/web/modules/ui/components/search-bar/index.test.tsx @@ -0,0 +1,45 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SearchBar } from "./index"; + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + Search: () =>
, +})); + +describe("SearchBar", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default placeholder", () => { + render( {}} />); + + expect(screen.getByPlaceholderText("Search by survey name")).toBeInTheDocument(); + expect(screen.getByTestId("search-icon")).toBeInTheDocument(); + }); + + test("renders with custom placeholder", () => { + render( {}} placeholder="Custom placeholder" />); + + expect(screen.getByPlaceholderText("Custom placeholder")).toBeInTheDocument(); + }); + + test("displays the provided value", () => { + render( {}} />); + + const input = screen.getByPlaceholderText("Search by survey name") as HTMLInputElement; + expect(input.value).toBe("test query"); + }); + + test("applies custom className", () => { + const { container } = render( {}} className="custom-class" />); + + const searchBarContainer = container.firstChild as HTMLElement; + expect(searchBarContainer).toHaveClass("custom-class"); + expect(searchBarContainer).toHaveClass("flex"); + expect(searchBarContainer).toHaveClass("h-8"); + }); +}); diff --git a/apps/web/modules/ui/components/secondary-navigation/index.test.tsx b/apps/web/modules/ui/components/secondary-navigation/index.test.tsx new file mode 100644 index 0000000000..f48ea19074 --- /dev/null +++ b/apps/web/modules/ui/components/secondary-navigation/index.test.tsx @@ -0,0 +1,67 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SecondaryNavigation } from "./index"; + +// Mock next/link +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href, onClick }: any) => ( + + {children} + + ), +})); + +describe("SecondaryNavigation", () => { + afterEach(() => { + cleanup(); + }); + + const mockNavigation = [ + { id: "tab1", label: "Tab 1", href: "/tab1" }, + { id: "tab2", label: "Tab 2", href: "/tab2" }, + { id: "tab3", label: "Tab 3", onClick: vi.fn() }, + { id: "tab4", label: "Hidden Tab", href: "/tab4", hidden: true }, + ]; + + test("renders navigation items correctly", () => { + render(); + + // Visible tabs + expect(screen.getByText("Tab 1")).toBeInTheDocument(); + expect(screen.getByText("Tab 2")).toBeInTheDocument(); + expect(screen.getByText("Tab 3")).toBeInTheDocument(); + + // Hidden tab + expect(screen.queryByText("Hidden Tab")).not.toBeInTheDocument(); + }); + + test("renders links for items with href", () => { + render(); + + const links = screen.getAllByTestId("mock-link"); + expect(links).toHaveLength(2); // tab1 and tab2 + + expect(links[0]).toHaveAttribute("href", "/tab1"); + expect(links[1]).toHaveAttribute("href", "/tab2"); + }); + + test("renders buttons for items without href", () => { + render(); + + const button = screen.getByRole("button", { name: "Tab 3" }); + expect(button).toBeInTheDocument(); + }); + + test("calls onClick function when button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole("button", { name: "Tab 3" }); + await user.click(button); + + expect(mockNavigation[2].onClick).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ui/components/segment-title/index.test.tsx b/apps/web/modules/ui/components/segment-title/index.test.tsx new file mode 100644 index 0000000000..a2ef2f4189 --- /dev/null +++ b/apps/web/modules/ui/components/segment-title/index.test.tsx @@ -0,0 +1,58 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SegmentTitle } from "./index"; + +// Mock lucide-react icon +vi.mock("lucide-react", () => ({ + UsersIcon: () =>
, +})); + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => + key === "environments.surveys.edit.send_survey_to_audience_who_match" + ? "Send survey to audience who match the following attributes:" + : key, + }), +})); + +describe("SegmentTitle", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with title and description", () => { + render(); + + expect(screen.getByText("Test Segment")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + expect(screen.getByTestId("users-icon")).toBeInTheDocument(); + }); + + test("renders with title and no description", () => { + render(); + + expect(screen.getByText("Test Segment")).toBeInTheDocument(); + expect(screen.getByTestId("users-icon")).toBeInTheDocument(); + }); + + test("renders private segment text when isPrivate is true", () => { + render(); + + expect( + screen.getByText("Send survey to audience who match the following attributes:") + ).toBeInTheDocument(); + expect(screen.queryByText("Test Segment")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Description")).not.toBeInTheDocument(); + expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument(); + }); + + test("renders correctly with null description", () => { + render(); + + expect(screen.getByText("Test Segment")).toBeInTheDocument(); + expect(screen.getByTestId("users-icon")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/select/index.test.tsx b/apps/web/modules/ui/components/select/index.test.tsx new file mode 100644 index 0000000000..b65f413e11 --- /dev/null +++ b/apps/web/modules/ui/components/select/index.test.tsx @@ -0,0 +1,85 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "./index"; + +// Mock radix-ui portal to make testing easier +vi.mock("@radix-ui/react-select", async () => { + const actual = await vi.importActual("@radix-ui/react-select"); + return { + ...actual, + Portal: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +describe("Select", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the select trigger correctly", () => { + render( + + ); + + const trigger = screen.getByText("Select an option"); + expect(trigger).toBeInTheDocument(); + expect(trigger.closest("button")).toHaveClass("border-slate-300"); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + test("renders select trigger without arrow when hideArrow is true", () => { + render( + + ); + + const chevronIcon = document.querySelector(".opacity-50"); + expect(chevronIcon).not.toBeInTheDocument(); + }); + + test("renders select trigger with arrow by default", () => { + render( + + ); + + const chevronIcon = document.querySelector(".opacity-50"); + expect(chevronIcon).toBeInTheDocument(); + }); + + test("applies custom className to select trigger", () => { + render( + + ); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/modules/ui/components/settings-id/index.test.tsx b/apps/web/modules/ui/components/settings-id/index.test.tsx new file mode 100644 index 0000000000..4fdfc9e0c5 --- /dev/null +++ b/apps/web/modules/ui/components/settings-id/index.test.tsx @@ -0,0 +1,35 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { SettingsId } from "./index"; + +describe("SettingsId", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the title and id correctly", () => { + render(); + + const element = screen.getByText(/Survey ID: survey-123/); + expect(element).toBeInTheDocument(); + expect(element.tagName.toLowerCase()).toBe("p"); + }); + + test("applies correct styling", () => { + render(); + + const element = screen.getByText(/Environment ID: env-456/); + expect(element).toHaveClass("py-1"); + expect(element).toHaveClass("text-xs"); + expect(element).toHaveClass("text-slate-400"); + }); + + test("renders with very long id", () => { + const longId = "a".repeat(100); + render(); + + const element = screen.getByText(`API Key: ${longId}`); + expect(element).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/shuffle-option-select/index.test.tsx b/apps/web/modules/ui/components/shuffle-option-select/index.test.tsx new file mode 100644 index 0000000000..3c6dc1207b --- /dev/null +++ b/apps/web/modules/ui/components/shuffle-option-select/index.test.tsx @@ -0,0 +1,104 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TShuffleOption } from "@formbricks/types/surveys/types"; +import { ShuffleOptionSelect } from "./index"; + +// Mock Select component +vi.mock("@/modules/ui/components/select", () => ({ + Select: ({ children, onValueChange, value }: any) => ( +
+ +
{children}
+
+ ), + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( +
document.dispatchEvent(new CustomEvent("select-item", { detail: value }))}> + {children} +
+ ), + SelectTrigger: ({ children }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) =>
{placeholder}
, +})); + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => (key === "environments.surveys.edit.select_ordering" ? "Select ordering" : key), + }), +})); + +describe("ShuffleOptionSelect", () => { + afterEach(() => { + cleanup(); + }); + + const shuffleOptionsTypes = { + none: { id: "none", label: "Don't shuffle", show: true }, + all: { id: "all", label: "Shuffle all options", show: true }, + exceptLast: { id: "exceptLast", label: "Shuffle all except last option", show: true }, + }; + + const mockUpdateQuestion = vi.fn(); + + test("renders with default value", () => { + render( + + ); + + expect(screen.getByTestId("select")).toBeInTheDocument(); + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "none"); + expect(screen.getByTestId("select-value")).toHaveTextContent("Select ordering"); + }); + + test("renders all shuffle options", () => { + render( + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(3); + expect(selectItems[0]).toHaveTextContent("Don't shuffle"); + expect(selectItems[1]).toHaveTextContent("Shuffle all options"); + expect(selectItems[2]).toHaveTextContent("Shuffle all except last option"); + }); + + test("only renders visible shuffle options", () => { + const limitedOptions = { + none: { id: "none", label: "Don't shuffle", show: true }, + all: { id: "all", label: "Shuffle all options", show: false }, // This one shouldn't show + exceptLast: { id: "exceptLast", label: "Shuffle all except last option", show: true }, + }; + + render( + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(2); + expect(selectItems[0]).toHaveTextContent("Don't shuffle"); + expect(selectItems[1]).toHaveTextContent("Shuffle all except last option"); + }); +}); diff --git a/apps/web/modules/ui/components/skeleton-loader/index.test.tsx b/apps/web/modules/ui/components/skeleton-loader/index.test.tsx new file mode 100644 index 0000000000..65a6634736 --- /dev/null +++ b/apps/web/modules/ui/components/skeleton-loader/index.test.tsx @@ -0,0 +1,73 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SkeletonLoader } from "./index"; + +// Mock the Skeleton component +vi.mock("@/modules/ui/components/skeleton", () => ({ + Skeleton: ({ className, children }: { className: string; children: React.ReactNode }) => ( +
+ {children} +
+ ), +})); + +describe("SkeletonLoader", () => { + afterEach(() => { + cleanup(); + }); + + test("renders summary skeleton loader correctly", () => { + render(); + + expect(screen.getByTestId("skeleton-loader-summary")).toBeInTheDocument(); + expect(screen.getByTestId("mocked-skeleton")).toHaveClass("group"); + expect(screen.getByTestId("mocked-skeleton")).toHaveClass("space-y-4"); + expect(screen.getByTestId("mocked-skeleton")).toHaveClass("rounded-xl"); + expect(screen.getByTestId("mocked-skeleton")).toHaveClass("bg-white"); + expect(screen.getByTestId("mocked-skeleton")).toHaveClass("p-6"); + + // Check for skeleton elements inside + const skeletonElements = document.querySelectorAll(".bg-slate-200"); + expect(skeletonElements.length).toBeGreaterThan(0); + }); + + test("renders response skeleton loader correctly", () => { + render(); + + expect(screen.getByTestId("skeleton-loader-response")).toBeInTheDocument(); + expect(screen.getByTestId("skeleton-loader-response")).toHaveClass("group"); + expect(screen.getByTestId("skeleton-loader-response")).toHaveClass("space-y-4"); + expect(screen.getByTestId("skeleton-loader-response")).toHaveClass("rounded-lg"); + expect(screen.getByTestId("skeleton-loader-response")).toHaveClass("bg-white"); + expect(screen.getByTestId("skeleton-loader-response")).toHaveClass("p-6"); + + // Check for skeleton elements inside + const skeletonElements = document.querySelectorAll(".bg-slate-200"); + expect(skeletonElements.length).toBeGreaterThan(0); + + // Check for profile skeleton + const profileSkeleton = document.querySelector(".h-12.w-12.flex-shrink-0.rounded-full"); + expect(profileSkeleton).toBeInTheDocument(); + }); + + test("renders different structures for summary and response types", () => { + const { rerender } = render(); + + const summaryContainer = screen.getByTestId("skeleton-loader-summary"); + expect(summaryContainer).toBeInTheDocument(); + expect(summaryContainer).toHaveClass("rounded-xl"); + expect(summaryContainer).toHaveClass("border-slate-200"); + + // Rerender with response type + rerender(); + + expect(screen.queryByTestId("skeleton-loader-summary")).not.toBeInTheDocument(); + expect(screen.getByTestId("skeleton-loader-response")).toBeInTheDocument(); + + // Response type has no border class + const responseContainer = screen.getByTestId("skeleton-loader-response"); + expect(responseContainer).not.toHaveClass("border"); + expect(responseContainer).not.toHaveClass("border-slate-200"); + }); +}); diff --git a/apps/web/modules/ui/components/skeleton/index.test.tsx b/apps/web/modules/ui/components/skeleton/index.test.tsx new file mode 100644 index 0000000000..14bde3cd73 --- /dev/null +++ b/apps/web/modules/ui/components/skeleton/index.test.tsx @@ -0,0 +1,40 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { Skeleton } from "./index"; + +describe("Skeleton", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default styling", () => { + const { container } = render(); + const skeletonElement = container.firstChild as HTMLElement; + + expect(skeletonElement).toBeInTheDocument(); + expect(skeletonElement).toHaveClass("animate-pulse"); + expect(skeletonElement).toHaveClass("rounded-full"); + expect(skeletonElement).toHaveClass("bg-slate-200"); + }); + + test("passes additional props", () => { + const { container } = render(); + const skeletonElement = container.firstChild as HTMLElement; + + expect(skeletonElement).toHaveAttribute("data-testid", "test-skeleton"); + expect(skeletonElement).toHaveAttribute("aria-label", "Loading"); + }); + + test("renders with children", () => { + const { container } = render( + +
Content
+
+ ); + + const skeletonElement = container.firstChild as HTMLElement; + expect(skeletonElement).toBeInTheDocument(); + expect(skeletonElement.textContent).toBe("Content"); + }); +}); diff --git a/apps/web/modules/ui/components/slider/index.test.tsx b/apps/web/modules/ui/components/slider/index.test.tsx new file mode 100644 index 0000000000..163ab8b37c --- /dev/null +++ b/apps/web/modules/ui/components/slider/index.test.tsx @@ -0,0 +1,97 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Slider } from "./index"; + +// Mock Radix UI Slider components +vi.mock("@radix-ui/react-slider", () => ({ + Root: ({ className, defaultValue, value, onValueChange, disabled, ...props }: any) => ( +
{ + if (!disabled && onValueChange) { + // Simulate slider change on click (simplified for testing) + const newValue = value ? [value[0] + 10] : [50]; + onValueChange(newValue); + } + }} + {...props} + /> + ), + Track: ({ className, children }: any) => ( +
+ {children} +
+ ), + Range: ({ className }: any) =>
, + Thumb: ({ className }: any) =>
, +})); + +describe("Slider", () => { + afterEach(() => { + cleanup(); + }); + + test("renders with default props", () => { + render(); + + expect(screen.getByTestId("slider-root")).toBeInTheDocument(); + expect(screen.getByTestId("slider-track")).toBeInTheDocument(); + expect(screen.getByTestId("slider-range")).toBeInTheDocument(); + expect(screen.getByTestId("slider-thumb")).toBeInTheDocument(); + }); + + test("applies custom className", () => { + render(); + + const sliderRoot = screen.getByTestId("slider-root"); + expect(sliderRoot).toHaveClass("custom-class"); + expect(sliderRoot).toHaveClass("relative"); + expect(sliderRoot).toHaveClass("flex"); + expect(sliderRoot).toHaveClass("w-full"); + }); + + test("accepts defaultValue prop", () => { + render(); + + const sliderRoot = screen.getByTestId("slider-root"); + expect(sliderRoot).toHaveAttribute("data-value", "25"); + }); + + test("handles value changes", async () => { + const handleValueChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const sliderRoot = screen.getByTestId("slider-root"); + expect(sliderRoot).toHaveAttribute("data-value", "30"); + + await user.click(sliderRoot); + + expect(handleValueChange).toHaveBeenCalledWith([40]); + }); + + test("renders in disabled state", () => { + render(); + + const sliderRoot = screen.getByTestId("slider-root"); + expect(sliderRoot).toHaveAttribute("data-disabled", "true"); + }); + + test("doesn't call onValueChange when disabled", async () => { + const handleValueChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const sliderRoot = screen.getByTestId("slider-root"); + await user.click(sliderRoot); + + expect(handleValueChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ui/components/stacked-cards-container/index.test.tsx b/apps/web/modules/ui/components/stacked-cards-container/index.test.tsx new file mode 100644 index 0000000000..c2b0a960c9 --- /dev/null +++ b/apps/web/modules/ui/components/stacked-cards-container/index.test.tsx @@ -0,0 +1,106 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { StackedCardsContainer } from "./index"; + +describe("StackedCardsContainer", () => { + afterEach(() => { + cleanup(); + }); + + test("renders children correctly", () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + test("renders with 'simple' arrangement", () => { + const { container } = render( + +
Test Content
+
+ ); + + // Should have only one div with specific classes for "none" layout + const mainContainer = container.firstChild as HTMLElement; + expect(mainContainer).toHaveClass("flex"); + expect(mainContainer).toHaveClass("flex-col"); + expect(mainContainer).toHaveClass("items-center"); + expect(mainContainer).toHaveClass("justify-center"); + expect(mainContainer).toHaveClass("rounded-xl"); + expect(mainContainer).toHaveClass("border"); + expect(mainContainer).toHaveClass("border-slate-200"); + + // Should not have shadow cards + const allDivs = container.querySelectorAll("div"); + expect(allDivs.length).toBe(2); // Main container + child div + }); + + test("renders with 'casual' arrangement", () => { + const { container } = render( + +
Test Content
+
+ ); + + // Should have a group container + const groupContainer = container.firstChild as HTMLElement; + expect(groupContainer).toHaveClass("group"); + expect(groupContainer).toHaveClass("relative"); + + // Should have shadow cards + const allDivs = container.querySelectorAll("div"); + expect(allDivs.length).toBe(5); // Group + 2 shadow cards + content container + child div + + // Check for shadow cards with rotation + const shadowCards = container.querySelectorAll(".absolute"); + expect(shadowCards.length).toBe(2); + expect(shadowCards[0]).toHaveClass("-rotate-6"); + expect(shadowCards[1]).toHaveClass("-rotate-3"); + }); + + test("renders with 'straight' arrangement", () => { + const { container } = render( + +
Test Content
+
+ ); + + // Should have a group container + const groupContainer = container.firstChild as HTMLElement; + expect(groupContainer).toHaveClass("group"); + expect(groupContainer).toHaveClass("relative"); + + // Should have shadow cards + const allDivs = container.querySelectorAll("div"); + expect(allDivs.length).toBe(5); // Group + 2 shadow cards + content container + child div + + // Check for shadow cards with translation + const shadowCards = container.querySelectorAll(".absolute"); + expect(shadowCards.length).toBe(2); + expect(shadowCards[0]).toHaveClass("-translate-y-8"); + expect(shadowCards[1]).toHaveClass("-translate-y-4"); + }); + + test("falls back to 'simple' arrangement for unknown type", () => { + // @ts-ignore - Testing with invalid input + const { container } = render( + +
Test Content
+
+ ); + + // Should have the same structure as "none" + const mainContainer = container.firstChild as HTMLElement; + expect(mainContainer).toHaveClass("flex"); + expect(mainContainer).toHaveClass("flex-col"); + + const allDivs = container.querySelectorAll("div"); + expect(allDivs.length).toBe(2); // Main container + child div + }); +}); diff --git a/apps/web/modules/ui/components/styling-tabs/index.test.tsx b/apps/web/modules/ui/components/styling-tabs/index.test.tsx new file mode 100644 index 0000000000..4f4aa6214d --- /dev/null +++ b/apps/web/modules/ui/components/styling-tabs/index.test.tsx @@ -0,0 +1,109 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { StylingTabs } from "./index"; + +describe("StylingTabs", () => { + afterEach(() => { + cleanup(); + }); + + const mockOptions = [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]; + + test("renders with all options", () => { + render( {}} />); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + + test("selects default option when provided", () => { + render( + {}} /> + ); + + const option2Input = screen.getByLabelText("Option 2"); + expect(option2Input).toBeChecked(); + }); + + test("calls onChange handler when option is selected", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByText("Option 3")); + + expect(handleChange).toHaveBeenCalledWith("option3"); + }); + + test("renders with label and subLabel", () => { + render( + {}} + label="Test Label" + subLabel="Test Sublabel" + /> + ); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Test Sublabel")).toBeInTheDocument(); + }); + + test("renders with custom className", () => { + const { container } = render( + {}} className="custom-class" /> + ); + + const radioGroup = container.querySelector('[role="radiogroup"]'); + expect(radioGroup).toHaveClass("custom-class"); + }); + + test("renders with custom tabsContainerClassName", () => { + const { container } = render( + {}} + tabsContainerClassName="custom-tabs-class" + /> + ); + + const tabsContainer = container.querySelector(".overflow-hidden.rounded-md.border"); + expect(tabsContainer).toHaveClass("custom-tabs-class"); + }); + + test("renders options with icons when provided", () => { + const optionsWithIcons = [ + { value: "option1", label: "Option 1", icon: Icon 1 }, + { value: "option2", label: "Option 2", icon: Icon 2 }, + ]; + + render( {}} />); + + expect(screen.getByTestId("icon1")).toBeInTheDocument(); + expect(screen.getByTestId("icon2")).toBeInTheDocument(); + }); + + test("applies selected styling to active option", async () => { + const user = userEvent.setup(); + + render( {}} />); + + const option1Label = screen.getByText("Option 1").closest("label"); + const option2Label = screen.getByText("Option 2").closest("label"); + + await user.click(screen.getByText("Option 2")); + + expect(option1Label).not.toHaveClass("bg-slate-100"); + expect(option2Label).toHaveClass("bg-slate-100"); + }); +}); diff --git a/apps/web/modules/ui/components/survey-status-indicator/index.test.tsx b/apps/web/modules/ui/components/survey-status-indicator/index.test.tsx new file mode 100644 index 0000000000..4ce385f8fd --- /dev/null +++ b/apps/web/modules/ui/components/survey-status-indicator/index.test.tsx @@ -0,0 +1,169 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SurveyStatusIndicator } from "./index"; + +// Mock the tooltip component +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Mock the lucide-react icons +vi.mock("lucide-react", () => ({ + CheckIcon: () =>
, + ClockIcon: () =>
, + PauseIcon: () =>
, + PencilIcon: () =>
, +})); + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations: Record = { + "common.gathering_responses": "Gathering responses", + "common.survey_scheduled": "Survey scheduled", + "common.survey_paused": "Survey paused", + "common.survey_completed": "Survey completed", + }; + return translations[key] || key; + }, + }), +})); + +describe("SurveyStatusIndicator", () => { + afterEach(() => { + cleanup(); + }); + + test("renders inProgress status correctly without tooltip", () => { + const { container } = render(); + + // Find the green dot using container query instead of getByText + const greenDotContainer = container.querySelector(".relative.flex.h-3.w-3"); + expect(greenDotContainer).toBeInTheDocument(); + + // Check the children elements + const pingElement = greenDotContainer?.querySelector(".animate-ping-slow"); + const dotElement = greenDotContainer?.querySelector(".relative.inline-flex"); + + expect(pingElement).toHaveClass("bg-green-500"); + expect(dotElement).toHaveClass("bg-green-500"); + + // Should not render tooltip components + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders scheduled status correctly without tooltip", () => { + const { container } = render(); + + // Find the clock icon container + const clockIconContainer = container.querySelector(".rounded-full.bg-slate-300.p-1"); + expect(clockIconContainer).toBeInTheDocument(); + + // Find the clock icon inside + const clockIcon = clockIconContainer?.querySelector("[data-testid='clock-icon']"); + expect(clockIcon).toBeInTheDocument(); + + // Should not render tooltip components + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders paused status correctly without tooltip", () => { + const { container } = render(); + + // Find the pause icon container + const pauseIconContainer = container.querySelector(".rounded-full.bg-slate-300.p-1"); + expect(pauseIconContainer).toBeInTheDocument(); + + // Find the pause icon inside + const pauseIcon = pauseIconContainer?.querySelector("[data-testid='pause-icon']"); + expect(pauseIcon).toBeInTheDocument(); + + // Should not render tooltip components + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders completed status correctly without tooltip", () => { + const { container } = render(); + + // Find the check icon container + const checkIconContainer = container.querySelector(".rounded-full.bg-slate-200.p-1"); + expect(checkIconContainer).toBeInTheDocument(); + + // Find the check icon inside + const checkIcon = checkIconContainer?.querySelector("[data-testid='check-icon']"); + expect(checkIcon).toBeInTheDocument(); + + // Should not render tooltip components + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders draft status correctly without tooltip", () => { + const { container } = render(); + + // Find the pencil icon container + const pencilIconContainer = container.querySelector(".rounded-full.bg-slate-300.p-1"); + expect(pencilIconContainer).toBeInTheDocument(); + + // Find the pencil icon inside + const pencilIcon = pencilIconContainer?.querySelector("[data-testid='pencil-icon']"); + expect(pencilIcon).toBeInTheDocument(); + + // Should not render tooltip components + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders with tooltip when tooltip prop is true", () => { + render(); + + // Should render tooltip components + expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-content")).toBeInTheDocument(); + + // Should have the right content in the tooltip + const tooltipContent = screen.getByTestId("tooltip-content"); + expect(tooltipContent).toHaveTextContent("Gathering responses"); + }); + + test("renders scheduled status with tooltip correctly", () => { + const { container } = render(); + + expect(screen.getByTestId("tooltip-content")).toHaveTextContent("Survey scheduled"); + + // Use container query to find the first clock icon + const clockIcon = container.querySelector("[data-testid='clock-icon']"); + expect(clockIcon).toBeInTheDocument(); + }); + + test("renders paused status with tooltip correctly", () => { + const { container } = render(); + + expect(screen.getByTestId("tooltip-content")).toHaveTextContent("Survey paused"); + + // Use container query to find the first pause icon + const pauseIcon = container.querySelector("[data-testid='pause-icon']"); + expect(pauseIcon).toBeInTheDocument(); + }); + + test("renders completed status with tooltip correctly", () => { + const { container } = render(); + + expect(screen.getByTestId("tooltip-content")).toHaveTextContent("Survey completed"); + + // Use container query to find the first check icon + const checkIcon = container.querySelector("[data-testid='check-icon']"); + expect(checkIcon).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/survey/index.test.tsx b/apps/web/modules/ui/components/survey/index.test.tsx new file mode 100644 index 0000000000..e8c3e7512c --- /dev/null +++ b/apps/web/modules/ui/components/survey/index.test.tsx @@ -0,0 +1,171 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { SurveyInline } from "./index"; +import * as recaptchaModule from "./recaptcha"; + +// Mock survey loading functionality +vi.mock("@/modules/ui/components/survey/recaptcha", () => ({ + loadRecaptchaScript: vi.fn().mockResolvedValue(undefined), + executeRecaptcha: vi.fn().mockResolvedValue("mock-recaptcha-token"), +})); + +describe("SurveyInline", () => { + const mockRenderSurvey = vi.fn(); + + beforeEach(() => { + // Mock fetch to prevent actual network requests + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("console.log('Survey script loaded');"), + } as Response); + + // Setup window.formbricksSurveys + window.formbricksSurveys = { + renderSurveyInline: vi.fn(), + renderSurveyModal: vi.fn(), + renderSurvey: mockRenderSurvey, + onFilePick: vi.fn(), + }; + + // Mock script loading functionality + Object.defineProperty(window, "formbricksSurveys", { + value: { + renderSurveyInline: vi.fn(), + renderSurveyModal: vi.fn(), + renderSurvey: mockRenderSurvey, + onFilePick: vi.fn(), + }, + writable: true, + }); + + // Mock the document.createElement and appendChild methods + // to avoid actual DOM manipulation in tests + const originalCreateElement = document.createElement; + + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + if (tagName === "script") { + const mockScript = originalCreateElement.call(document, "script"); + Object.defineProperty(mockScript, "textContent", { + set: () => { + /* mock setter */ + }, + get: () => "", + }); + return mockScript; + } + return originalCreateElement.call(document, tagName); + }); + + vi.spyOn(document.head, "appendChild").mockImplementation(() => document.head); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + // @ts-ignore + delete window.formbricksSurveys; + }); + + test("renders a container with the correct ID", () => { + const { container } = render( + + ); + + const surveyContainer = container.querySelector('[id^="formbricks-survey-container"]'); + expect(surveyContainer).toBeInTheDocument(); + expect(surveyContainer).toHaveClass("h-full"); + expect(surveyContainer).toHaveClass("w-full"); + }); + + test("calls renderSurvey with correct props when formbricksSurveys is available", async () => { + const mockSurvey = { id: "survey1" }; + + render( + + ); + + // Verify the mock was called with correct props + expect(mockRenderSurvey).toHaveBeenCalled(); + + const callArgs = mockRenderSurvey.mock.calls[0][0]; + expect(callArgs.survey).toBe(mockSurvey); + expect(callArgs.mode).toBe("inline"); + expect(callArgs.containerId).toMatch(/formbricks-survey-container/); + }); + + test("doesn't load recaptcha script when isSpamProtectionEnabled is false", async () => { + const loadRecaptchaScriptMock = vi.mocked(recaptchaModule.loadRecaptchaScript); + loadRecaptchaScriptMock.mockClear(); // Reset mock call counts + + render( + + ); + + expect(loadRecaptchaScriptMock).not.toHaveBeenCalled(); + }); + + test("handles script loading error gracefully", async () => { + // Remove formbricksSurveys to test script loading + // @ts-ignore + delete window.formbricksSurveys; + + // Mock fetch to reject + vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Failed to load script")); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + ); + + // Wait for the error to be logged + await vi.waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith("Failed to load the surveys package: ", expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + + test("provides a getRecaptchaToken function to the survey renderer", async () => { + const executeRecaptchaMock = vi.mocked(recaptchaModule.executeRecaptcha); + executeRecaptchaMock.mockClear(); // Reset mock call counts + + render( + + ); + + // Verify the mock was called with the right function + expect(mockRenderSurvey).toHaveBeenCalled(); + + // Get the getRecaptchaToken function from the props + const callArgs = mockRenderSurvey.mock.calls[0][0]; + expect(callArgs.getRecaptchaToken).toBeDefined(); + + // Call the function to verify it works + await callArgs.getRecaptchaToken(); + expect(executeRecaptchaMock).toHaveBeenCalledWith("test-site-key"); + }); +}); diff --git a/apps/web/modules/ui/components/switch/index.test.tsx b/apps/web/modules/ui/components/switch/index.test.tsx new file mode 100644 index 0000000000..77c3d1d488 --- /dev/null +++ b/apps/web/modules/ui/components/switch/index.test.tsx @@ -0,0 +1,112 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Switch } from "./index"; + +// Mock radix-ui components +vi.mock("@radix-ui/react-switch", () => ({ + Root: ({ className, checked, onCheckedChange, disabled, id, "aria-label": ariaLabel }: any) => ( + + ), + Thumb: ({ className, checked }: any) => ( + + ), +})); + +describe("Switch", () => { + afterEach(() => { + cleanup(); + }); + + test("renders default switch correctly", () => { + render(); + + const switchRoot = screen.getByTestId("switch-root"); + expect(switchRoot).toBeInTheDocument(); + + // Check default state classes + expect(switchRoot).toHaveClass("peer"); + expect(switchRoot).toHaveClass("inline-flex"); + expect(switchRoot).toHaveClass("rounded-full"); + expect(switchRoot).toHaveClass("border-2"); + + // Check default state (unchecked) + expect(switchRoot).toHaveAttribute("data-state", "unchecked"); + + // Check thumb element + const switchThumb = screen.getByTestId("switch-thumb"); + expect(switchThumb).toBeInTheDocument(); + expect(switchThumb).toHaveAttribute("data-state", "unchecked"); + }); + + test("applies custom className", () => { + render(); + + const switchRoot = screen.getByTestId("switch-root"); + expect(switchRoot).toHaveClass("custom-class"); + }); + + test("renders in checked state", () => { + render(); + + const switchRoot = screen.getByTestId("switch-root"); + expect(switchRoot).toHaveAttribute("data-state", "checked"); + + const switchThumb = screen.getByTestId("switch-thumb"); + expect(switchThumb).toHaveAttribute("data-state", "checked"); + }); + + test("renders in disabled state", () => { + render(); + + const switchRoot = screen.getByTestId("switch-root"); + expect(switchRoot).toBeDisabled(); + }); + + test("handles onChange callback", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const switchRoot = screen.getByTestId("switch-root"); + await user.click(switchRoot); + + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + test("doesn't trigger onChange when disabled", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const switchRoot = screen.getByTestId("switch-root"); + await user.click(switchRoot); + + expect(handleChange).not.toHaveBeenCalled(); + }); + + test("passes props correctly", () => { + render(); + + const switchRoot = screen.getByTestId("switch-root"); + expect(switchRoot).toHaveAttribute("id", "test-switch"); + expect(switchRoot).toHaveAttribute("aria-label", "Toggle"); + }); +}); diff --git a/apps/web/modules/ui/components/tab-bar/index.test.tsx b/apps/web/modules/ui/components/tab-bar/index.test.tsx new file mode 100644 index 0000000000..270dcca879 --- /dev/null +++ b/apps/web/modules/ui/components/tab-bar/index.test.tsx @@ -0,0 +1,97 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TabBar } from "./index"; + +describe("TabBar", () => { + afterEach(() => { + cleanup(); + }); + + const mockTabs = [ + { id: "tab1", label: "Tab One" }, + { id: "tab2", label: "Tab Two" }, + { id: "tab3", label: "Tab Three" }, + ]; + + test("calls setActiveId when tab is clicked", async () => { + const handleSetActiveId = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByText("Tab Two")); + + expect(handleSetActiveId).toHaveBeenCalledTimes(1); + expect(handleSetActiveId).toHaveBeenCalledWith("tab2"); + }); + + test("renders tabs with icons", () => { + const tabsWithIcons = [ + { id: "tab1", label: "Tab One", icon: 🔍 }, + { id: "tab2", label: "Tab Two", icon: 📁 }, + ]; + + render( {}} />); + + expect(screen.getByTestId("icon1")).toBeInTheDocument(); + expect(screen.getByTestId("icon2")).toBeInTheDocument(); + }); + + test("applies custom className", () => { + const { container } = render( + {}} className="custom-class" /> + ); + + const tabContainer = container.firstChild as HTMLElement; + expect(tabContainer).toHaveClass("custom-class"); + }); + + test("applies activeTabClassName to active tab", () => { + render( + {}} + activeTabClassName="custom-active-class" + /> + ); + + const activeTab = screen.getByText("Tab One").closest("button"); + expect(activeTab).toHaveClass("custom-active-class"); + }); + + test("renders in disabled state", async () => { + const handleSetActiveId = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + const navContainer = screen.getByRole("navigation"); + expect(navContainer).toHaveClass("cursor-not-allowed"); + expect(navContainer).toHaveClass("opacity-50"); + + await user.click(screen.getByText("Tab Two")); + + expect(handleSetActiveId).not.toHaveBeenCalled(); + }); + + test("doesn't apply disabled styles when not disabled", () => { + render( + {}} tabStyle="button" disabled={false} /> + ); + + const navContainer = screen.getByRole("navigation"); + expect(navContainer).not.toHaveClass("cursor-not-allowed"); + expect(navContainer).not.toHaveClass("opacity-50"); + }); +}); diff --git a/apps/web/modules/ui/components/tab-toggle/index.test.tsx b/apps/web/modules/ui/components/tab-toggle/index.test.tsx new file mode 100644 index 0000000000..b3a28d307b --- /dev/null +++ b/apps/web/modules/ui/components/tab-toggle/index.test.tsx @@ -0,0 +1,121 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TabToggle } from "./index"; + +describe("TabToggle", () => { + afterEach(() => { + cleanup(); + }); + + const mockOptions = [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]; + + test("renders all options correctly", () => { + render( {}} />); + + expect(screen.getByLabelText("Option 1")).toBeInTheDocument(); + expect(screen.getByLabelText("Option 2")).toBeInTheDocument(); + expect(screen.getByLabelText("Option 3")).toBeInTheDocument(); + }); + + test("selects default option when provided", () => { + render( {}} />); + + const option1Radio = screen.getByLabelText("Option 1") as HTMLInputElement; + const option2Radio = screen.getByLabelText("Option 2") as HTMLInputElement; + const option3Radio = screen.getByLabelText("Option 3") as HTMLInputElement; + + expect(option1Radio.checked).toBe(false); + expect(option2Radio.checked).toBe(true); + expect(option3Radio.checked).toBe(false); + }); + + test("calls onChange handler when option is selected", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByLabelText("Option 2")); + + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith("option2"); + }); + + test("displays option labels correctly", () => { + render( {}} />); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + + test("applies correct styling to selected option", async () => { + const user = userEvent.setup(); + + render( {}} />); + + const option2Label = screen.getByText("Option 2").closest("label"); + expect(option2Label).not.toHaveClass("bg-white"); + + await user.click(screen.getByLabelText("Option 2")); + + expect(option2Label).toHaveClass("bg-white"); + }); + + test("renders in disabled state", () => { + render( {}} disabled={true} />); + + const option1Radio = screen.getByLabelText("Option 1") as HTMLInputElement; + const option2Radio = screen.getByLabelText("Option 2") as HTMLInputElement; + const option3Radio = screen.getByLabelText("Option 3") as HTMLInputElement; + + expect(option1Radio).toBeDisabled(); + expect(option2Radio).toBeDisabled(); + expect(option3Radio).toBeDisabled(); + + const labels = screen.getAllByRole("radio").map((radio) => radio.closest("label")); + labels.forEach((label) => { + expect(label).toHaveClass("cursor-not-allowed"); + expect(label).toHaveClass("opacity-50"); + }); + }); + + test("doesn't call onChange when disabled", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByLabelText("Option 2")); + + expect(handleChange).not.toHaveBeenCalled(); + }); + + test("renders with number values", () => { + const numberOptions = [ + { value: 1, label: "One" }, + { value: 2, label: "Two" }, + ]; + + render( {}} />); + + const option1Radio = screen.getByLabelText("One") as HTMLInputElement; + const option2Radio = screen.getByLabelText("Two") as HTMLInputElement; + + expect(option1Radio.checked).toBe(true); + expect(option2Radio.checked).toBe(false); + }); + + test("sets correct aria attributes", () => { + render( {}} />); + + const radioGroup = screen.getByRole("radiogroup"); + expect(radioGroup).toHaveAttribute("aria-labelledby", "test-id-toggle-label"); + }); +}); diff --git a/apps/web/modules/ui/components/table/index.test.tsx b/apps/web/modules/ui/components/table/index.test.tsx new file mode 100644 index 0000000000..35c5e09396 --- /dev/null +++ b/apps/web/modules/ui/components/table/index.test.tsx @@ -0,0 +1,202 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "./index"; + +describe("Table", () => { + afterEach(() => { + cleanup(); + }); + + test("renders table correctly", () => { + render(); + + const table = screen.getByTestId("test-table"); + expect(table).toBeInTheDocument(); + expect(table.tagName).toBe("TABLE"); + expect(table).toHaveClass("w-full"); + expect(table).toHaveClass("caption-bottom"); + expect(table).toHaveClass("text-sm"); + }); + + test("applies custom className to Table", () => { + render(
); + + const table = screen.getByTestId("test-table"); + expect(table).toHaveClass("custom-class"); + expect(table).toHaveClass("w-full"); + }); + + test("renders TableHeader correctly", () => { + render( +
+ + + Header + + +
+ ); + + const header = screen.getByTestId("test-header"); + expect(header).toBeInTheDocument(); + expect(header.tagName).toBe("THEAD"); + expect(header).toHaveClass("pointer-events-none"); + expect(header).toHaveClass("text-slate-800"); + }); + + test("renders TableBody correctly", () => { + render( + + + + Cell + + +
+ ); + + const body = screen.getByTestId("test-body"); + expect(body).toBeInTheDocument(); + expect(body.tagName).toBe("TBODY"); + }); + + test("renders TableFooter correctly", () => { + render( + + + + Footer + + +
+ ); + + const footer = screen.getByTestId("test-footer"); + expect(footer).toBeInTheDocument(); + expect(footer.tagName).toBe("TFOOT"); + expect(footer).toHaveClass("border-t"); + }); + + test("renders TableRow correctly", () => { + render( + + + + Cell + + +
+ ); + + const row = screen.getByTestId("test-row"); + expect(row).toBeInTheDocument(); + expect(row.tagName).toBe("TR"); + expect(row).toHaveClass("border-b"); + expect(row).toHaveClass("bg-white"); + expect(row).toHaveClass("hover:bg-slate-100"); + }); + + test("renders TableHead correctly", () => { + render( + + + + Header + + +
+ ); + + const head = screen.getByTestId("test-head"); + expect(head).toBeInTheDocument(); + expect(head.tagName).toBe("TH"); + expect(head).toHaveClass("h-12"); + expect(head).toHaveClass("px-4"); + expect(head).toHaveClass("text-left"); + expect(head).toHaveClass("align-middle"); + }); + + test("renders TableCell correctly", () => { + render( + + + + Cell + + +
+ ); + + const cell = screen.getByTestId("test-cell"); + expect(cell).toBeInTheDocument(); + expect(cell.tagName).toBe("TD"); + expect(cell).toHaveClass("p-4"); + expect(cell).toHaveClass("align-middle"); + }); + + test("renders TableCaption correctly", () => { + render( + + Caption +
+ ); + + const caption = screen.getByTestId("test-caption"); + expect(caption).toBeInTheDocument(); + expect(caption.tagName).toBe("CAPTION"); + expect(caption).toHaveClass("mt-4"); + expect(caption).toHaveClass("text-sm"); + expect(caption.textContent).toBe("Caption"); + }); + + test("renders full table structure correctly", () => { + render( + + A list of users + + + Name + Email + + + + + John Doe + john@example.com + + + Jane Smith + jane@example.com + + + + + Total: 2 users + + +
+ ); + + const table = screen.getByTestId("full-table"); + expect(table).toBeInTheDocument(); + + expect(screen.getByText("A list of users")).toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Email")).toBeInTheDocument(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("john@example.com")).toBeInTheDocument(); + expect(screen.getByText("Jane Smith")).toBeInTheDocument(); + expect(screen.getByText("jane@example.com")).toBeInTheDocument(); + expect(screen.getByText("Total: 2 users")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/tag/index.test.tsx b/apps/web/modules/ui/components/tag/index.test.tsx new file mode 100644 index 0000000000..0fcb4d4fb3 --- /dev/null +++ b/apps/web/modules/ui/components/tag/index.test.tsx @@ -0,0 +1,40 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { Tag } from "./index"; + +describe("Tag", () => { + afterEach(() => { + cleanup(); + }); + + test("renders tag with correct name", () => { + render( {}} />); + + expect(screen.getByText("Test Tag")).toBeInTheDocument(); + }); + + test("applies highlight class when highlight prop is true", () => { + const { container } = render( + {}} highlight={true} /> + ); + + const tagElement = container.firstChild as HTMLElement; + expect(tagElement).toHaveClass("animate-shake"); + }); + + test("does not apply highlight class when highlight prop is false", () => { + const { container } = render( + {}} highlight={false} /> + ); + + const tagElement = container.firstChild as HTMLElement; + expect(tagElement).not.toHaveClass("animate-shake"); + }); + + test("does not render delete icon when allowDelete is false", () => { + render( {}} allowDelete={false} />); + + expect(screen.queryByRole("img", { hidden: true })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/tags-combobox/index.test.tsx b/apps/web/modules/ui/components/tags-combobox/index.test.tsx new file mode 100644 index 0000000000..91261e7dd4 --- /dev/null +++ b/apps/web/modules/ui/components/tags-combobox/index.test.tsx @@ -0,0 +1,184 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TagsCombobox } from "./index"; + +// Mock components +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, size }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/command", () => ({ + Command: ({ children, filter }: any) => ( +
+ {children} +
+ ), + CommandGroup: ({ children }: any) =>
{children}
, + CommandInput: ({ placeholder, value, onValueChange, onKeyDown }: any) => ( + onValueChange(e.target.value)} + onKeyDown={onKeyDown} + /> + ), + CommandItem: ({ children, value, onSelect, className }: any) => ( +
onSelect(value)} className={className}> + {children} +
+ ), + CommandList: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/popover", () => ({ + Popover: ({ children, open }: any) => ( +
+ {children} +
+ ), + PopoverContent: ({ children, className }: any) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, asChild }: any) => ( +
+ {children} +
+ ), +})); + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations: Record = { + "environments.project.tags.add_tag": "Add tag", + "environments.project.tags.search_tags": "Search tags", + "environments.project.tags.add": "Add", + }; + return translations[key] || key; + }, + }), +})); + +describe("TagsCombobox", () => { + afterEach(() => { + cleanup(); + }); + + const mockTags = [ + { label: "Tag1", value: "tag1" }, + { label: "Tag2", value: "tag2" }, + { label: "Tag3", value: "tag3" }, + ]; + + const mockCurrentTags = [{ label: "Tag1", value: "tag1" }]; + + const mockProps = { + tags: mockTags, + currentTags: mockCurrentTags, + addTag: vi.fn(), + createTag: vi.fn(), + searchValue: "", + setSearchValue: vi.fn(), + open: false, + setOpen: vi.fn(), + }; + + test("renders with default props", () => { + render(); + + expect(screen.getByTestId("popover")).toBeInTheDocument(); + expect(screen.getByTestId("popover-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("button")).toBeInTheDocument(); + expect(screen.getByTestId("button")).toHaveTextContent("Add tag"); + }); + + test("renders popover content when open is true", () => { + render(); + + expect(screen.getByTestId("popover")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("popover-content")).toBeInTheDocument(); + expect(screen.getByTestId("command")).toBeInTheDocument(); + expect(screen.getByTestId("command-input")).toBeInTheDocument(); + expect(screen.getByTestId("command-list")).toBeInTheDocument(); + }); + + test("shows available tags excluding current tags", () => { + render(); + + const commandItems = screen.getAllByTestId("command-item"); + expect(commandItems).toHaveLength(2); // Should show Tag2 and Tag3 but not Tag1 (which is in currentTags) + expect(commandItems[0]).toHaveAttribute("data-value", "tag2"); + expect(commandItems[1]).toHaveAttribute("data-value", "tag3"); + }); + + test("calls addTag when a tag is selected", async () => { + const user = userEvent.setup(); + const addTagMock = vi.fn(); + const setOpenMock = vi.fn(); + + render(); + + const tag2Item = screen.getAllByTestId("command-item")[0]; + await user.click(tag2Item); + + expect(addTagMock).toHaveBeenCalledWith("tag2"); + expect(setOpenMock).toHaveBeenCalledWith(false); + }); + + test("calls createTag when Enter is pressed with a new tag", async () => { + const user = userEvent.setup(); + const createTagMock = vi.fn(); + + render(); + + const input = screen.getByTestId("command-input"); + await user.type(input, "{enter}"); + + expect(createTagMock).toHaveBeenCalledWith("NewTag"); + }); + + test("doesn't show create option when searchValue matches existing tag", () => { + render(); + + const commandItems = screen.getAllByTestId("command-item"); + expect(commandItems).toHaveLength(2); // Tag2 and Tag3 + expect(commandItems[0]).toHaveAttribute("data-value", "tag2"); + expect(screen.queryByRole("button", { name: /\+ Add Tag2/i })).not.toBeInTheDocument(); + }); + + test("resets search value when closed", () => { + const setSearchValueMock = vi.fn(); + const { rerender } = render( + + ); + + // Change to closed state + rerender( + + ); + + expect(setSearchValueMock).toHaveBeenCalledWith(""); + }); + + test("updates placeholder based on available tags", () => { + // With available tags + const { rerender } = render(); + + expect(screen.getByTestId("command-input")).toHaveAttribute("placeholder", "Search tags"); + + // Without available tags + rerender(); + + expect(screen.getByTestId("command-input")).toHaveAttribute("placeholder", "Add tag"); + }); +}); diff --git a/apps/web/modules/ui/components/targeting-indicator/index.test.tsx b/apps/web/modules/ui/components/targeting-indicator/index.test.tsx new file mode 100644 index 0000000000..8e42b3f9fb --- /dev/null +++ b/apps/web/modules/ui/components/targeting-indicator/index.test.tsx @@ -0,0 +1,93 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TBaseFilters, TSegment } from "@formbricks/types/segment"; +import { TargetingIndicator } from "./index"; + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations: Record = { + "environments.surveys.edit.audience": "Audience", + "environments.surveys.edit.targeted": "Targeted", + "environments.surveys.edit.everyone": "Everyone", + "environments.surveys.edit.only_people_who_match_your_targeting_can_be_surveyed": + "Only people who match your targeting can be surveyed", + "environments.surveys.edit.without_a_filter_all_of_your_users_can_be_surveyed": + "Without a filter all of your users can be surveyed", + }; + return translations[key] || key; + }, + }), +})); + +describe("TargetingIndicator", () => { + afterEach(() => { + cleanup(); + }); + + test("renders correctly with null segment", () => { + render(); + + expect(screen.getByText("Audience:")).toBeInTheDocument(); + expect(screen.getByText("Everyone")).toBeInTheDocument(); + expect(screen.getByText("Without a filter all of your users can be surveyed")).toBeInTheDocument(); + + // Should show the filter icon when no targeting + const filterIcon = document.querySelector("svg"); + expect(filterIcon).toBeInTheDocument(); + }); + + test("renders correctly with empty filters", () => { + const emptySegment: TSegment = { + id: "seg_123", + environmentId: "env_123", + title: "Test Segment", + description: "A test segment", + isPrivate: false, + createdAt: new Date(), + updatedAt: new Date(), + filters: [], + surveys: [], + }; + + render(); + + expect(screen.getByText("Audience:")).toBeInTheDocument(); + expect(screen.getByText("Everyone")).toBeInTheDocument(); + expect(screen.getByText("Without a filter all of your users can be surveyed")).toBeInTheDocument(); + + // Should show the filter icon when no targeting + const filterIcon = document.querySelector("svg"); + expect(filterIcon).toBeInTheDocument(); + }); + + test("renders correctly with filters", () => { + const segmentWithFilters: TSegment = { + id: "seg_123", + environmentId: "env_123", + title: "Test Segment", + description: "A test segment", + isPrivate: false, + createdAt: new Date(), + updatedAt: new Date(), + filters: [ + { + id: "filter_123", + }, + ] as unknown as TBaseFilters, + surveys: [], + }; + + render(); + + expect(screen.getByText("Audience:")).toBeInTheDocument(); + expect(screen.getByText("Targeted")).toBeInTheDocument(); + expect(screen.getByText("Only people who match your targeting can be surveyed")).toBeInTheDocument(); + + // Should show the users icon when targeting is active + const usersIcon = document.querySelector("svg"); + expect(usersIcon).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/theme-styling-preview-survey/index.test.tsx b/apps/web/modules/ui/components/theme-styling-preview-survey/index.test.tsx new file mode 100644 index 0000000000..ae6d3876d6 --- /dev/null +++ b/apps/web/modules/ui/components/theme-styling-preview-survey/index.test.tsx @@ -0,0 +1,302 @@ +import "@testing-library/jest-dom/vitest"; +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 } from "@formbricks/types/surveys/types"; +import { ThemeStylingPreviewSurvey } from "./index"; + +// Mock required components +vi.mock("@/modules/ui/components/client-logo", () => ({ + ClientLogo: ({ projectLogo, previewSurvey }: any) => ( +
+ {projectLogo?.url ? "Logo" : "No Logo"} +
+ ), +})); + +vi.mock("@/modules/ui/components/media-background", () => ({ + MediaBackground: ({ children, isEditorView }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/preview-survey/components/modal", () => ({ + Modal: ({ children, isOpen, placement, darkOverlay, clickOutsideClose, previewMode }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/reset-progress-button", () => ({ + ResetProgressButton: ({ onClick }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/survey", () => ({ + SurveyInline: ({ survey, isPreviewMode, isBrandingEnabled, languageCode }: any) => ( +
+ Survey Content +
+ ), +})); + +// Mock framer-motion +vi.mock("framer-motion", async () => { + const actual = await vi.importActual("framer-motion"); + return { + ...actual, + motion: { + div: ({ children, className, animate }: any) => ( +
+ {children} +
+ ), + }, + }; +}); + +// Mock tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations: Record = { + "common.link_survey": "Link Survey", + "common.app_survey": "App Survey", + }; + return translations[key] || key; + }, + }), +})); + +describe("ThemeStylingPreviewSurvey", () => { + afterEach(() => { + cleanup(); + }); + + const mockSurvey: TSurvey = { + id: "survey1", + name: "Test Survey", + type: "link", + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + status: "draft", + languages: {}, + projectOverwrites: { + placement: "bottomRight", + darkOverlay: true, + clickOutsideClose: true, + }, + } as TSurvey; + + const mockProject = { + id: "project1", + name: "Test Project", + placement: "center", + darkOverlay: false, + clickOutsideClose: false, + inAppSurveyBranding: true, + linkSurveyBranding: true, + logo: { url: "http://example.com/logo.png" }, + styling: { + roundness: 8, + cardBackgroundColor: { light: "#ffffff" }, + isLogoHidden: false, + }, + } as any; + + test("renders correctly with link survey type", () => { + const setPreviewType = vi.fn(); + + render( + + ); + + // Check if browser header elements are rendered + expect(screen.getByText("Preview")).toBeInTheDocument(); + expect(screen.getByTestId("reset-progress-button")).toBeInTheDocument(); + + // Check if MediaBackground is rendered for link survey + const mediaBackground = screen.getByTestId("media-background"); + expect(mediaBackground).toBeInTheDocument(); + expect(mediaBackground).toHaveAttribute("data-editor", "true"); + + // Check if ClientLogo is rendered + const clientLogo = screen.getByTestId("client-logo"); + expect(clientLogo).toBeInTheDocument(); + expect(clientLogo).toHaveAttribute("data-preview", "true"); + + // Check if SurveyInline is rendered with correct props + const surveyInline = screen.getByTestId("survey-inline"); + expect(surveyInline).toBeInTheDocument(); + expect(surveyInline).toHaveAttribute("data-survey-type", "link"); + expect(surveyInline).toHaveAttribute("data-preview-mode", "true"); + expect(surveyInline).toHaveAttribute("data-branding-enabled", "true"); + + // Check if toggle buttons are rendered + expect(screen.getByText("Link Survey")).toBeInTheDocument(); + expect(screen.getByText("App Survey")).toBeInTheDocument(); + }); + + test("renders correctly with app survey type", () => { + const setPreviewType = vi.fn(); + + render( + + ); + + // Check if browser header elements are rendered + expect(screen.getByText("Your web app")).toBeInTheDocument(); + expect(screen.getByTestId("reset-progress-button")).toBeInTheDocument(); + + // Check if Modal is rendered for app survey + const previewModal = screen.getByTestId("preview-modal"); + expect(previewModal).toBeInTheDocument(); + expect(previewModal).toHaveAttribute("data-open", "true"); + expect(previewModal).toHaveAttribute("data-placement", "bottomRight"); + expect(previewModal).toHaveAttribute("data-dark-overlay", "true"); + expect(previewModal).toHaveAttribute("data-click-outside-close", "true"); + expect(previewModal).toHaveAttribute("data-preview-mode", "desktop"); + + // Check if SurveyInline is rendered with correct props + const surveyInline = screen.getByTestId("survey-inline"); + expect(surveyInline).toBeInTheDocument(); + expect(surveyInline).toHaveAttribute("data-survey-type", "app"); + expect(surveyInline).toHaveAttribute("data-preview-mode", "true"); + expect(surveyInline).toHaveAttribute("data-branding-enabled", "true"); + }); + + test("handles toggle between link and app survey types", async () => { + const setPreviewType = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + // Click on App Survey button + await user.click(screen.getByText("App Survey")); + + // Check if setPreviewType was called with "app" + expect(setPreviewType).toHaveBeenCalledWith("app"); + + // Clean up and reset + cleanup(); + setPreviewType.mockClear(); + + // Render with app type + render( + + ); + + // Click on Link Survey button + await user.click(screen.getByText("Link Survey")); + + // Check if setPreviewType was called with "link" + expect(setPreviewType).toHaveBeenCalledWith("link"); + }); + + test("handles reset progress button click", async () => { + const setPreviewType = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + // Click the reset progress button + await user.click(screen.getByTestId("reset-progress-button")); + + // Check if a new survey component renders with a new key + // Since we can't easily check the key directly, we can verify the content is still there + expect(screen.getByTestId("survey-inline")).toBeInTheDocument(); + }); + + test("renders without logo when isLogoHidden is true", () => { + const setPreviewType = vi.fn(); + const projectWithHiddenLogo = { + ...mockProject, + styling: { + ...mockProject.styling, + isLogoHidden: true, + }, + }; + + render( + + ); + + // Check that the logo is not rendered + expect(screen.queryByTestId("client-logo")).not.toBeInTheDocument(); + }); + + test("uses project settings when projectOverwrites are not provided", () => { + const setPreviewType = vi.fn(); + const surveyWithoutOverwrites = { + ...mockSurvey, + projectOverwrites: undefined, + }; + + render( + + ); + + // Check if Modal uses project settings + const previewModal = screen.getByTestId("preview-modal"); + expect(previewModal).toHaveAttribute("data-placement", "center"); + expect(previewModal).toHaveAttribute("data-dark-overlay", "false"); + expect(previewModal).toHaveAttribute("data-click-outside-close", "false"); + }); +}); diff --git a/apps/web/modules/ui/components/toaster-client/index.test.tsx b/apps/web/modules/ui/components/toaster-client/index.test.tsx new file mode 100644 index 0000000000..77edecca5c --- /dev/null +++ b/apps/web/modules/ui/components/toaster-client/index.test.tsx @@ -0,0 +1,39 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ToasterClient } from "./index"; + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + Toaster: ({ toastOptions }: any) => ( +
+ Mock Toaster +
+ ), +})); + +describe("ToasterClient", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the Toaster component", () => { + const { getByTestId } = render(); + + const toaster = getByTestId("mock-toaster"); + expect(toaster).toBeInTheDocument(); + expect(toaster).toHaveTextContent("Mock Toaster"); + }); + + test("passes the correct toast options to the Toaster", () => { + const { getByTestId } = render(); + + const toaster = getByTestId("mock-toaster"); + const toastOptions = JSON.parse(toaster.getAttribute("data-toast-options") || "{}"); + + expect(toastOptions).toHaveProperty("success"); + expect(toastOptions).toHaveProperty("error"); + expect(toastOptions.success).toHaveProperty("className", "formbricks__toast__success"); + expect(toastOptions.error).toHaveProperty("className", "formbricks__toast__error"); + }); +}); diff --git a/apps/web/modules/ui/components/tooltip/index.test.tsx b/apps/web/modules/ui/components/tooltip/index.test.tsx new file mode 100644 index 0000000000..5ab04d6956 --- /dev/null +++ b/apps/web/modules/ui/components/tooltip/index.test.tsx @@ -0,0 +1,196 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipRenderer, TooltipTrigger } from "./index"; + +// Mock radix-ui tooltip +vi.mock("@radix-ui/react-tooltip", () => ({ + Root: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + Provider: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + Trigger: ({ children, asChild, ...props }: any) => ( +
+ {children} +
+ ), + Content: ({ children, sideOffset, className, ...props }: any) => ( +
+ {children} +
+ ), + Tooltip: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +describe("Tooltip", () => { + afterEach(() => { + cleanup(); + }); + + test("renders basic tooltip components", () => { + render( + + + Hover me + Tooltip content + + + ); + + expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-root")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-content")).toBeInTheDocument(); + + expect(screen.getByText("Hover me")).toBeInTheDocument(); + expect(screen.getByText("Tooltip content")).toBeInTheDocument(); + }); + + test("applies correct default classes to TooltipContent", () => { + render( + + + Hover me + Tooltip content + + + ); + + const contentElement = screen.getByTestId("tooltip-content"); + expect(contentElement).toHaveClass("animate-in"); + expect(contentElement).toHaveClass("fade-in-50"); + expect(contentElement).toHaveClass("z-50"); + expect(contentElement).toHaveClass("rounded-md"); + expect(contentElement).toHaveClass("border"); + expect(contentElement).toHaveClass("border-slate-100"); + expect(contentElement).toHaveClass("bg-white"); + }); + + test("applies custom classes to TooltipContent", () => { + render( + + + Hover me + Tooltip content + + + ); + + const contentElement = screen.getByTestId("tooltip-content"); + expect(contentElement).toHaveClass("custom-class"); + }); + + test("accepts custom sideOffset prop", () => { + render( + + + Hover me + Tooltip content + + + ); + + const contentElement = screen.getByTestId("tooltip-content"); + expect(contentElement).toHaveAttribute("data-side-offset", "10"); + }); + + test("uses default sideOffset when not provided", () => { + render( + + + Hover me + Tooltip content + + + ); + + const contentElement = screen.getByTestId("tooltip-content"); + expect(contentElement).toHaveAttribute("data-side-offset", "4"); + }); + + test("sets asChild prop on trigger", () => { + render( + + + + + + Tooltip content + + + ); + + const triggerElement = screen.getByTestId("tooltip-trigger"); + expect(triggerElement).toHaveAttribute("data-as-child", "true"); + }); +}); + +describe("TooltipRenderer", () => { + afterEach(() => { + cleanup(); + }); + + test("renders tooltip with content", () => { + render( + + + + ); + + expect(screen.getByText("Trigger")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-content")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-content")).toHaveTextContent("Tooltip text"); + expect(screen.getByTestId("tooltip-content")).toHaveClass("test-class"); + }); + + test("applies triggerClass to the trigger wrapper", () => { + render( + + + + ); + + const trigger = screen.getByTestId("tooltip-trigger").firstChild; + expect(trigger).toHaveClass("trigger-class"); + }); + + test("doesn't render tooltip when shouldRender is false", () => { + render( + + + + ); + + expect(screen.getByText("Trigger")).toBeInTheDocument(); + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + expect(screen.queryByTestId("tooltip-content")).not.toBeInTheDocument(); + }); + + test("renders tooltip with React node as content", () => { + render( + + Complex tooltip content +
+ }> + + + ); + + const tooltipContent = screen.getByTestId("tooltip-content"); + expect(tooltipContent).toBeInTheDocument(); + expect(tooltipContent.innerHTML).toContain("Complex tooltip "); + expect(tooltipContent.innerHTML).toContain("content"); + }); +}); diff --git a/apps/web/modules/ui/components/typography/index.test.tsx b/apps/web/modules/ui/components/typography/index.test.tsx new file mode 100644 index 0000000000..25cabf8f79 --- /dev/null +++ b/apps/web/modules/ui/components/typography/index.test.tsx @@ -0,0 +1,158 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { H1, H2, H3, H4, InlineCode, Large, Lead, List, Muted, P, Quote, Small } from "./index"; + +describe("Typography Components", () => { + afterEach(() => { + cleanup(); + }); + + test("renders H1 correctly", () => { + const { container } = render(

Heading 1

); + const h1Element = container.querySelector("h1"); + + expect(h1Element).toBeInTheDocument(); + expect(h1Element).toHaveTextContent("Heading 1"); + expect(h1Element?.className).toContain("text-4xl"); + expect(h1Element?.className).toContain("font-bold"); + expect(h1Element?.className).toContain("tracking-tight"); + expect(h1Element?.className).toContain("text-slate-800"); + }); + + test("renders H2 correctly", () => { + const { container } = render(

Heading 2

); + const h2Element = container.querySelector("h2"); + + expect(h2Element).toBeInTheDocument(); + expect(h2Element).toHaveTextContent("Heading 2"); + expect(h2Element?.className).toContain("text-3xl"); + expect(h2Element?.className).toContain("font-semibold"); + expect(h2Element?.className).toContain("border-b"); + expect(h2Element?.className).toContain("text-slate-800"); + }); + + test("renders H3 correctly", () => { + const { container } = render(

Heading 3

); + const h3Element = container.querySelector("h3"); + + expect(h3Element).toBeInTheDocument(); + expect(h3Element).toHaveTextContent("Heading 3"); + expect(h3Element?.className).toContain("text-2xl"); + expect(h3Element?.className).toContain("font-semibold"); + expect(h3Element?.className).toContain("text-slate-800"); + }); + + test("renders H4 correctly", () => { + const { container } = render(

Heading 4

); + const h4Element = container.querySelector("h4"); + + expect(h4Element).toBeInTheDocument(); + expect(h4Element).toHaveTextContent("Heading 4"); + expect(h4Element?.className).toContain("text-xl"); + expect(h4Element?.className).toContain("font-semibold"); + expect(h4Element?.className).toContain("text-slate-800"); + }); + + test("renders Lead correctly", () => { + const { container } = render(Lead paragraph); + const pElement = container.querySelector("p"); + + expect(pElement).toBeInTheDocument(); + expect(pElement).toHaveTextContent("Lead paragraph"); + expect(pElement?.className).toContain("text-xl"); + expect(pElement?.className).toContain("text-slate-800"); + }); + + test("renders P correctly", () => { + const { container } = render(

Standard paragraph

); + const pElement = container.querySelector("p"); + + expect(pElement).toBeInTheDocument(); + expect(pElement).toHaveTextContent("Standard paragraph"); + expect(pElement?.className).toContain("leading-7"); + }); + + test("renders Large correctly", () => { + const { container } = render(Large text); + const divElement = container.querySelector("div"); + + expect(divElement).toBeInTheDocument(); + expect(divElement).toHaveTextContent("Large text"); + expect(divElement?.className).toContain("text-lg"); + expect(divElement?.className).toContain("font-semibold"); + }); + + test("renders Small correctly", () => { + const { container } = render(Small text); + const pElement = container.querySelector("p"); + + expect(pElement).toBeInTheDocument(); + expect(pElement).toHaveTextContent("Small text"); + expect(pElement?.className).toContain("text-sm"); + expect(pElement?.className).toContain("font-medium"); + }); + + test("renders Muted correctly", () => { + const { container } = render(Muted text); + const spanElement = container.querySelector("span"); + + expect(spanElement).toBeInTheDocument(); + expect(spanElement).toHaveTextContent("Muted text"); + expect(spanElement?.className).toContain("text-sm"); + expect(spanElement?.className).toContain("text-muted-foreground"); + }); + + test("renders InlineCode correctly", () => { + const { container } = render(code); + const codeElement = container.querySelector("code"); + + expect(codeElement).toBeInTheDocument(); + expect(codeElement).toHaveTextContent("code"); + expect(codeElement?.className).toContain("font-mono"); + expect(codeElement?.className).toContain("text-sm"); + expect(codeElement?.className).toContain("font-semibold"); + }); + + test("renders List correctly", () => { + const { container } = render( + +
  • Item 1
  • +
  • Item 2
  • +
    + ); + const ulElement = container.querySelector("ul"); + const liElements = container.querySelectorAll("li"); + + expect(ulElement).toBeInTheDocument(); + expect(liElements.length).toBe(2); + expect(ulElement?.className).toContain("list-disc"); + expect(liElements[0]).toHaveTextContent("Item 1"); + expect(liElements[1]).toHaveTextContent("Item 2"); + }); + + test("renders Quote correctly", () => { + const { container } = render(Quoted text); + const blockquoteElement = container.querySelector("blockquote"); + + expect(blockquoteElement).toBeInTheDocument(); + expect(blockquoteElement).toHaveTextContent("Quoted text"); + expect(blockquoteElement?.className).toContain("border-l-2"); + expect(blockquoteElement?.className).toContain("italic"); + }); + + test("applies custom className to components", () => { + const { container } = render(

    Custom Heading

    ); + const h1Element = container.querySelector("h1"); + + expect(h1Element).toHaveClass("custom-class"); + expect(h1Element).toHaveClass("text-4xl"); // Should still have default classes + }); + + test("passes additional props to components", () => { + const { container } = render(

    Test Heading

    ); + const h1Element = container.querySelector("h1"); + + expect(h1Element).toHaveAttribute("data-testid", "test-heading"); + }); +}); diff --git a/apps/web/modules/ui/components/upgrade-prompt/index.test.tsx b/apps/web/modules/ui/components/upgrade-prompt/index.test.tsx new file mode 100644 index 0000000000..8d1f052585 --- /dev/null +++ b/apps/web/modules/ui/components/upgrade-prompt/index.test.tsx @@ -0,0 +1,132 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { UpgradePrompt } from "./index"; + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, asChild, variant }: any) => ( + + ), +})); + +vi.mock("lucide-react", () => ({ + KeyIcon: () =>
    , +})); + +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children, target, rel }: any) => ( + + {children} + + ), +})); + +describe("UpgradePrompt", () => { + afterEach(() => { + cleanup(); + }); + + const mockProps = { + title: "Upgrade Your Account", + description: "Get access to premium features by upgrading your account.", + buttons: [ + { text: "Upgrade Now", href: "/pricing" }, + { text: "Learn More", href: "/features" }, + ] as [any, any], + }; + + test("renders component with correct content", () => { + render(); + + // Check if title and description are rendered + expect(screen.getByText("Upgrade Your Account")).toBeInTheDocument(); + expect(screen.getByText("Get access to premium features by upgrading your account.")).toBeInTheDocument(); + + // Check if the KeyIcon is rendered + expect(screen.getByTestId("key-icon")).toBeInTheDocument(); + + // Check if buttons are rendered with correct text + expect(screen.getByText("Upgrade Now")).toBeInTheDocument(); + expect(screen.getByText("Learn More")).toBeInTheDocument(); + }); + + test("renders buttons with correct links", () => { + render(); + + // Check if buttons have correct href attributes + const primaryLink = screen.getByText("Upgrade Now").closest("a"); + const secondaryLink = screen.getByText("Learn More").closest("a"); + + expect(primaryLink).toHaveAttribute("href", "/pricing"); + expect(secondaryLink).toHaveAttribute("href", "/features"); + + // Check if links have correct attributes + expect(primaryLink).toHaveAttribute("target", "_blank"); + expect(primaryLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(secondaryLink).toHaveAttribute("target", "_blank"); + expect(secondaryLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + test("handles onClick for buttons without href", async () => { + const primaryOnClick = vi.fn(); + const secondaryOnClick = vi.fn(); + const user = userEvent.setup(); + + const propsWithClickHandlers = { + ...mockProps, + buttons: [ + { text: "Primary Action", onClick: primaryOnClick }, + { text: "Secondary Action", onClick: secondaryOnClick }, + ] as [any, any], + }; + + render(); + + // Click the buttons and check if handlers are called + await user.click(screen.getByText("Primary Action")); + await user.click(screen.getByText("Secondary Action")); + + expect(primaryOnClick).toHaveBeenCalledTimes(1); + expect(secondaryOnClick).toHaveBeenCalledTimes(1); + }); + + test("renders with mixed button types (href and onClick)", () => { + const secondaryOnClick = vi.fn(); + + const mixedProps = { + ...mockProps, + buttons: [ + { text: "Primary Link", href: "/primary" }, + { text: "Secondary Button", onClick: secondaryOnClick }, + ] as [any, any], + }; + + render(); + + // Check primary button is a link + const primaryButton = screen.getByText("Primary Link"); + expect(primaryButton.closest("a")).toHaveAttribute("href", "/primary"); + + // Check secondary button is not a link + const secondaryButton = screen.getByText("Secondary Button"); + expect(secondaryButton.closest("a")).toBeNull(); + }); + + test("applies asChild and variant correctly to buttons", () => { + render(); + + // In our mock, we're checking data attributes that represent the props + const primaryButton = screen.getByText("Upgrade Now").closest("button"); + const secondaryButton = screen.getByText("Learn More").closest("button"); + + expect(primaryButton).toHaveAttribute("data-as-child", "true"); + expect(primaryButton).toHaveAttribute("data-variant", "default"); + + expect(secondaryButton).toHaveAttribute("data-as-child", "true"); + expect(secondaryButton).toHaveAttribute("data-variant", "secondary"); + }); +}); diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 61b119cb9b..c55aebc052 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -61,6 +61,7 @@ export default defineConfig({ "modules/setup/**/signup/**", "modules/setup/**/layout.tsx", "modules/survey/follow-ups/**", + "modules/ui/components/icons/**", "app/share/**", "lib/shortUrl/**", "modules/ee/contacts/[contactId]/**", diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index e15b12302c..0cf0dc529a 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -274,6 +274,8 @@ export const ZSurveyPictureChoice = z.object({ imageUrl: z.string(), }); +export type TSurveyPictureChoice = z.infer; + export type TSurveyQuestionChoice = z.infer; // Logic types diff --git a/sonar-project.properties b/sonar-project.properties index e4bde865b0..96b0b03ebe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false sonar.sourceEncoding=UTF-8 # Coverage -sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/** -sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/** \ No newline at end of file +sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,apps/web/modules/ui/components/icons/** +sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,apps/web/modules/ui/components/icons/** \ No newline at end of file