chore: add tests to package/surveys/src/components/questions (#5694)

This commit is contained in:
victorvhs017
2025-05-08 01:42:25 +07:00
committed by GitHub
parent b0495a8a42
commit 115bea2792
28 changed files with 6602 additions and 30 deletions

View File

@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
@@ -28,4 +28,5 @@ afterEach(() => {
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
- You don't need to mock @tolgee/react
- You don't need to mock @tolgee/react
- Use "import "@testing-library/jest-dom/vitest";"

View File

@@ -1,6 +1,10 @@
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -257,3 +261,238 @@ describe("generateResponseTableColumns", () => {
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});
describe("ResponseTableColumns", () => {
afterEach(() => {
cleanup();
});
test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: true,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeDefined();
expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail");
// Call the header function to trigger the t function call with "common.verified_email"
if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") {
verifiedEmailColumn.header();
expect(mockT).toHaveBeenCalledWith("common.verified_email");
}
});
test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: false,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Column Implementations", () => {
afterEach(() => {
cleanup();
});
test("dateColumn renders with formatted date", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
expect(dateColumn).toBeDefined();
// Call the header function to test it returns the expected value
expect(dateColumn?.header?.()).toBe("common.date");
// Mock a response with a date to test the cell function
const mockRow = {
original: { createdAt: "2023-01-01T12:00:00Z" },
} as any;
// Call the cell function and check the formatted date
dateColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z"));
});
test("personColumn renders anonymous when person is null", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Test header content
const headerResult = personColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with no person
const mockRow = {
original: { person: null },
} as any;
// Mock the t function for this specific call
t.mockReturnValueOnce("Anonymous User");
// Call the cell function and check it returns "Anonymous"
const cellResult = personColumn?.cell?.({ row: mockRow } as any);
expect(t).toHaveBeenCalledWith("common.anonymous");
expect(cellResult?.props?.children).toBe("Anonymous User");
});
test("personColumn renders person identifier when person exists", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Mock a response with a person
const mockRow = {
original: {
person: { id: "123", attributes: { email: "test@example.com" } },
contactAttributes: { name: "John Doe" },
},
} as any;
// Call the cell function
personColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith(
mockRow.original.person,
mockRow.original.contactAttributes
);
});
test("tagsColumn returns undefined when tags is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
expect(tagsColumn).toBeDefined();
// Mock a response with no tags
const mockRow = {
original: { tags: null },
} as any;
// Call the cell function
const cellResult = tagsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the variable column for var1
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
expect(var1Column).toBeDefined();
// Test the header
const headerResult = var1Column?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a string variable
const mockRow = {
original: { variables: { var1: "Test Value" } },
} as any;
// Call the cell function
const cellResult = var1Column?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Test Value");
// Test with a number variable
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
expect(var2Column).toBeDefined();
const mockRowNumber = {
original: { variables: { var2: 42 } },
} as any;
const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any);
expect(cellResultNumber?.props.children).toBe(42);
});
test("hiddenFieldColumns render when fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the hidden field column
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeDefined();
// Test the header
const headerResult = hfColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a hidden field value
const mockRow = {
original: { responseData: { hf1: "Hidden Value" } },
} as any;
// Call the cell function
const cellResult = hfColumn?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Hidden Value");
});
test("hiddenFieldColumns are empty when fieldIds don't exist", () => {
// Create a survey with no hidden field IDs
const surveyWithNoHiddenFields = {
...mockSurvey,
hiddenFields: { enabled: true }, // no fieldIds
};
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
// Check that no hidden field columns were created
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeUndefined();
});
});

View File

@@ -0,0 +1,263 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ResponseFilter } from "./ResponseFilter";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getSurveyFilterDataAction: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
generateQuestionAndFilterOptions: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useParams: vi.fn(),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [[vi.fn()]],
}));
vi.mock("./QuestionsComboBox", () => ({
QuestionsComboBox: ({ onChangeValue }) => (
<div data-testid="questions-combo-box">
<button onClick={() => onChangeValue({ id: "q1", label: "Question 1", type: "OpenText" })}>
Select Question
</button>
</div>
),
OptionsType: {
QUESTIONS: "Questions",
ATTRIBUTES: "Attributes",
TAGS: "Tags",
LANGUAGES: "Languages",
},
}));
// Update the mock for QuestionFilterComboBox to always render
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox",
() => ({
QuestionFilterComboBox: () => (
<div data-testid="filter-combo-box">
<button data-testid="select-filter-btn">Select Filter</button>
<button data-testid="select-filter-type-btn">Select Filter Type</button>
</div>
),
})
);
describe("ResponseFilter", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockSelectedFilter = {
filter: [],
onlyComplete: false,
};
const mockSelectedOptions = {
questionFilterOptions: [
{
type: TSurveyQuestionTypeEnum.OpenText,
filterOptions: ["equals", "does not equal"],
filterComboBoxOptions: [],
id: "q1",
},
],
questionOptions: [
{
label: "Questions",
type: "Questions",
option: [
{ id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText },
],
},
],
} as any;
const mockSetSelectedFilter = vi.fn();
const mockSetSelectedOptions = vi.fn();
const mockSurvey = {
id: "survey1",
environmentId: "env1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
createdBy: "user1",
questions: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
triggers: [],
displayOption: "displayOnce",
} as unknown as TSurvey;
beforeEach(() => {
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: mockSelectedFilter,
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
vi.mocked(getSurveyFilterDataAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({
questionFilterOptions: mockSelectedOptions.questionFilterOptions,
questionOptions: mockSelectedOptions.questionOptions,
});
});
test("renders with default state", () => {
render(<ResponseFilter survey={mockSurvey} />);
expect(screen.getByText("Filter")).toBeInTheDocument();
});
test("opens the filter popover when clicked", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
});
test("fetches filter data when opened", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" });
expect(mockSetSelectedOptions).toHaveBeenCalled();
});
test("handles adding new filter", async () => {
// Start with an empty filter
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify there's no filter yet
expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument();
// Add a new filter and check that the questions combo box appears
await userEvent.click(screen.getByText("common.add_filter"));
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
});
test("handles only complete checkbox toggle", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByRole("checkbox"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
});
test("handles selecting question and filter options", async () => {
// Setup with a pre-populated filter to ensure the filter components are rendered
const setSelectedFilterMock = vi.fn();
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [
{
questionType: { id: "q1", label: "Question 1", type: "OpenText" },
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
onlyComplete: false,
},
setSelectedFilter: setSelectedFilterMock,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify both combo boxes are rendered
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument();
// Use data-testid to find our buttons instead of text
await userEvent.click(screen.getByText("Select Question"));
await userEvent.click(screen.getByTestId("select-filter-btn"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(setSelectedFilterMock).toHaveBeenCalled();
});
test("handles clear all filters", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByText("common.clear_all"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
});
test("uses sharing key action when on sharing page", async () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "env1",
surveyId: "survey1",
sharingKey: "share123",
});
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
sharingKey: "share123",
environmentId: "env1",
});
});
});

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
describe("datetime utils", () => {
@@ -9,7 +9,11 @@ describe("datetime utils", () => {
});
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
const date = new Date("2025-05-06");
// Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
});

View File

@@ -193,7 +193,7 @@ export const EmailCustomizationSettings = ({
<div className="mb-10">
<Small>{t("environments.settings.general.logo_in_email_header")}</Small>
<div className="mb-6 mt-2 flex items-center gap-4">
<div className="mt-2 mb-6 flex items-center gap-4">
{logoUrl && (
<div className="flex flex-col gap-2">
<div className="flex w-max items-center justify-center rounded-lg border border-slate-200 px-4 py-2">
@@ -256,7 +256,7 @@ export const EmailCustomizationSettings = ({
</Button>
</div>
</div>
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10">
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pt-10 pb-4">
<Image
data-testid="email-customization-preview-image"
src={logoUrl || fbLogoUrl}
@@ -284,7 +284,7 @@ export const EmailCustomizationSettings = ({
)}
{hasWhiteLabelPermission && isReadOnly && (
<Alert variant="warning" className="mb-6 mt-4">
<Alert variant="warning" className="mt-4 mb-6">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>

View File

@@ -237,7 +237,7 @@ export const FileInput = ({
/>
{file.uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
className="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(idx)}>
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
@@ -255,7 +255,7 @@ export const FileInput = ({
</p>
{file.uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
className="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(idx)}>
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
@@ -295,7 +295,7 @@ export const FileInput = ({
/>
{selectedFiles[0].uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
className="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(0)}>
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
@@ -311,7 +311,7 @@ export const FileInput = ({
</p>
{selectedFiles[0].uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
className="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(0)}>
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>

View File

@@ -24,7 +24,7 @@ export default defineConfig({
"**/mocks/**", // Mock directories
"**/__mocks__/**", // Jest-style mock directories
"**/constants.ts", // Constants files
"**/route.ts", // Next.js API routes
"**/route.{ts,tsx}", // Next.js API routes
"**/openapi.ts", // OpenAPI spec files
"**/openapi-document.ts", // OpenAPI-related document files
"**/types/**", // Type definition folders
@@ -37,6 +37,7 @@ export default defineConfig({
"**/instrumentation.ts", // Next.js instrumentation files
"**/instrumentation-node.ts", // Next.js Node.js instrumentation files
"**/vitestSetup.ts", // Vitest setup files
"**/*.setup.*", // Vitest setup files
"**/*.json", // JSON files
"**/*.mdx", // MDX files
"**/playwright/**", // Playwright E2E test files
@@ -74,6 +75,7 @@ export default defineConfig({
"lib/airtable/**",
"app/api/v1/integrations/**",
"lib/env.ts",
"**/cache/**",
],
},
},

View File

@@ -1,7 +1,12 @@
interface LabelProps {
text: string;
htmlForId?: string;
}
export function Label({ text }: Readonly<LabelProps>) {
return <label className="fb-text-subheading fb-font-normal fb-text-sm">{text}</label>;
export function Label({ text, htmlForId }: Readonly<LabelProps>) {
return (
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm">
{text}
</label>
);
}

View File

@@ -0,0 +1,335 @@
import { getUpdatedTtc } from "@/lib/ttc";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyAddressQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { AddressQuestion } from "./address-question";
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi
.fn()
.mockImplementation((val, lang) => (typeof val === "object" ? val[lang] || val.default : val)),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn().mockReturnValue({}),
useTtc: vi.fn(),
}));
const mockOnChange = vi.fn();
const mockOnSubmit = vi.fn();
const mockOnBack = vi.fn();
const mockSetTtc = vi.fn();
describe("AddressQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockQuestion: TSurveyAddressQuestion = {
id: "address-1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Address Question" },
subheader: { default: "Enter your address" },
required: true,
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Back" },
addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } },
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
city: { show: true, required: false, placeholder: { default: "City" } },
state: { show: true, required: false, placeholder: { default: "State" } },
zip: { show: true, required: false, placeholder: { default: "ZIP" } },
country: { show: true, required: false, placeholder: { default: "Country" } },
};
test("renders the address question with all fields", () => {
render(
<AddressQuestion
question={mockQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
expect(screen.getByText("Address Question")).toBeInTheDocument();
expect(screen.getByText("Enter your address")).toBeInTheDocument();
expect(screen.getByText("Address Line 1*")).toBeInTheDocument();
expect(screen.getByText("Address Line 2*")).toBeInTheDocument();
expect(screen.getByText("City*")).toBeInTheDocument();
expect(screen.getByText("State*")).toBeInTheDocument();
expect(screen.getByText("ZIP*")).toBeInTheDocument();
expect(screen.getByText("Country*")).toBeInTheDocument();
expect(screen.getByText("Submit")).toBeInTheDocument();
expect(screen.getByText("Back")).toBeInTheDocument();
});
test("renders question with media when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(
<AddressQuestion
question={questionWithMedia}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
expect(screen.getByRole("img")).toBeInTheDocument();
});
test("updates value when fields are changed", async () => {
const user = userEvent.setup();
render(
<AddressQuestion
question={mockQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
const addressLine1Input = screen.getByLabelText("Address Line 1*");
await user.type(addressLine1Input, "123 Main St");
expect(mockOnChange).toHaveBeenCalledWith({
"address-1": ["123 Main St", "", "", "", "", ""],
});
});
test("submits data when form is submitted", async () => {
const user = userEvent.setup();
vi.mocked(getUpdatedTtc).mockReturnValue({ "address-1": 1000 });
render(
<AddressQuestion
question={mockQuestion}
value={["123 Main St", "Apt 4", "City", "State", "12345", "Country"]}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
const submitButton = screen.getByText("Submit");
await user.click(submitButton);
expect(getUpdatedTtc).toHaveBeenCalled();
expect(mockSetTtc).toHaveBeenCalledWith({ "address-1": 1000 });
expect(mockOnSubmit).toHaveBeenCalledWith(
{ "address-1": ["123 Main St", "Apt 4", "City", "State", "12345", "Country"] },
{ "address-1": 1000 }
);
});
test("submits empty array when all fields are empty", async () => {
const user = userEvent.setup();
vi.mocked(getUpdatedTtc).mockReturnValue({ "address-1": 1000 });
// Create a modified question with no required fields to allow empty submission
const nonRequiredQuestion = {
...mockQuestion,
required: false,
addressLine1: { ...mockQuestion.addressLine1, required: false },
addressLine2: { ...mockQuestion.addressLine2, required: false },
city: { ...mockQuestion.city, required: false },
state: { ...mockQuestion.state, required: false },
zip: { ...mockQuestion.zip, required: false },
country: { ...mockQuestion.country, required: false },
};
render(
<AddressQuestion
question={nonRequiredQuestion}
value={["", "", "", "", "", ""]}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(getUpdatedTtc).toHaveBeenCalled();
expect(mockSetTtc).toHaveBeenCalledWith({ "address-1": 1000 });
expect(mockOnSubmit).toHaveBeenCalledWith({ "address-1": [] }, { "address-1": 1000 });
});
test("calls onBack when back button is clicked", async () => {
const user = userEvent.setup();
vi.mocked(getUpdatedTtc).mockReturnValue({ "address-1": 500 });
render(
<AddressQuestion
question={mockQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
const backButton = screen.getByText("Back");
await user.click(backButton);
expect(getUpdatedTtc).toHaveBeenCalled();
expect(mockSetTtc).toHaveBeenCalledWith({ "address-1": 500 });
expect(mockOnBack).toHaveBeenCalled();
});
test("doesn't render back button when isFirstQuestion is true", () => {
render(
<AddressQuestion
question={mockQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={true}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("handles field visibility based on question config", () => {
const customQuestion = {
...mockQuestion,
addressLine2: { ...mockQuestion.addressLine2, show: false },
state: { ...mockQuestion.state, show: false },
};
render(
<AddressQuestion
question={customQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
expect(screen.getByLabelText("Address Line 1*")).toBeInTheDocument();
expect(screen.queryByLabelText("Address Line 2")).not.toBeInTheDocument();
expect(screen.queryByLabelText("State*")).not.toBeInTheDocument();
expect(screen.getByLabelText("City*")).toBeInTheDocument();
});
test("handles required fields correctly", () => {
const customQuestion = {
...mockQuestion,
required: false,
addressLine1: { ...mockQuestion.addressLine1, required: true },
};
render(
<AddressQuestion
question={customQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
expect(screen.getByLabelText("Address Line 1*")).toBeInTheDocument();
expect(screen.getByLabelText("City")).toBeInTheDocument(); // Not required anymore
});
test("auto focuses the first field when autoFocusEnabled is true", () => {
render(
<AddressQuestion
question={mockQuestion}
onChange={mockOnChange}
onSubmit={mockOnSubmit}
onBack={mockOnBack}
isFirstQuestion={false}
isLastQuestion={false}
languageCode="default"
ttc={{}}
setTtc={mockSetTtc}
currentQuestionId="address-1"
autoFocusEnabled={true}
isBackButtonHidden={false}
/>
);
const addressLine1Input = screen.getByLabelText("Address Line 1*");
expect(document.activeElement).toBe(addressLine1Input);
});
});

View File

@@ -157,8 +157,9 @@ export function AddressQuestion({
return (
field.show && (
<div className="fb-space-y-1">
<Label text={isFieldRequired() ? `${field.label}*` : field.label} />
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
<Input
id={field.id}
key={field.id}
required={isFieldRequired()}
value={safeValue[index] || ""}

View File

@@ -0,0 +1,93 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyCalQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { CalEmbed } from "../general/cal-embed";
import { CalQuestion } from "./cal-question";
// Mock the CalEmbed component
vi.mock("../general/cal-embed", () => ({
CalEmbed: vi.fn(({ question }) => (
<div data-testid="cal-embed-mock">
Cal Embed for {question.calUserName}
{question.calHost && <span>Host: {question.calHost}</span>}
</div>
)),
}));
describe("CalQuestion", () => {
afterEach(() => {
cleanup();
});
const mockQuestion: TSurveyCalQuestion = {
id: "cal-question-1",
type: TSurveyQuestionTypeEnum.Cal,
headline: { default: "Schedule a meeting" },
subheader: { default: "Choose a time that works for you" },
required: true,
calUserName: "johndoe",
calHost: "cal.com",
};
const mockQuestionWithoutHost: TSurveyCalQuestion = {
id: "cal-question-2",
type: TSurveyQuestionTypeEnum.Cal,
headline: { default: "Schedule a meeting" },
required: false,
calUserName: "janedoe",
};
const defaultProps = {
question: mockQuestion,
value: null,
onChange: vi.fn(),
onSubmit: vi.fn(),
isInvalid: false,
direction: "vertical" as const,
languageCode: "en",
} as any;
test("renders with headline and subheader", () => {
render(<CalQuestion {...defaultProps} />);
expect(screen.getByText("Schedule a meeting")).toBeInTheDocument();
expect(screen.getByText("Choose a time that works for you")).toBeInTheDocument();
});
test("renders without subheader", () => {
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
expect(screen.getByText("Schedule a meeting")).toBeInTheDocument();
expect(screen.queryByText("Choose a time that works for you")).not.toBeInTheDocument();
});
test("renders CalEmbed component with correct props", () => {
render(<CalQuestion {...defaultProps} />);
expect(screen.getByTestId("cal-embed-mock")).toBeInTheDocument();
expect(screen.getByText("Cal Embed for johndoe")).toBeInTheDocument();
expect(screen.getByText("Host: cal.com")).toBeInTheDocument();
expect(CalEmbed).toHaveBeenCalledWith(
expect.objectContaining({
question: mockQuestion,
onSuccessfulBooking: expect.any(Function),
}),
{}
);
});
test("renders CalEmbed without host when not provided", () => {
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
expect(screen.getByTestId("cal-embed-mock")).toBeInTheDocument();
expect(screen.getByText("Cal Embed for janedoe")).toBeInTheDocument();
expect(screen.queryByText(/Host:/)).not.toBeInTheDocument();
});
test("does not add required indicator when question is optional", () => {
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
expect(screen.queryByText("*")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,131 @@
import { getUpdatedTtc } from "@/lib/ttc";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyConsentQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ConsentQuestion } from "./consent-question";
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: vi.fn().mockReturnValue({}),
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media">Question Media</div>,
}));
describe("ConsentQuestion", () => {
afterEach(() => {
cleanup();
});
const mockQuestion: TSurveyConsentQuestion = {
id: "consent-q",
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Headline" },
html: { default: "This is the consent text" },
label: { default: "I agree to the terms" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Back" },
required: true,
};
const defaultProps = {
question: mockQuestion,
value: "",
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "consent-q",
isBackButtonHidden: false,
};
test("renders consent question correctly", () => {
render(<ConsentQuestion {...defaultProps} />);
expect(screen.getByText("Consent Headline")).toBeInTheDocument();
expect(screen.getByText("I agree to the terms")).toBeInTheDocument();
expect(screen.getByText("Submit")).toBeInTheDocument();
expect(screen.getByText("Back")).toBeInTheDocument();
});
test("renders with media when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(<ConsentQuestion {...defaultProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("checkbox changes value when clicked", async () => {
const onChange = vi.fn();
render(<ConsentQuestion {...defaultProps} onChange={onChange} />);
const checkbox = screen.getByRole("checkbox");
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith({ "consent-q": "accepted" });
onChange.mockReset();
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith({ "consent-q": "" });
});
test("submits form with correct data", async () => {
const onSubmit = vi.fn();
render(<ConsentQuestion {...defaultProps} value="accepted" onSubmit={onSubmit} />);
const submitButton = screen.getByText("Submit");
await userEvent.click(submitButton);
expect(getUpdatedTtc).toHaveBeenCalled();
expect(onSubmit).toHaveBeenCalledWith({ "consent-q": "accepted" }, {});
});
test("back button triggers onBack handler", async () => {
const onBack = vi.fn();
render(<ConsentQuestion {...defaultProps} onBack={onBack} />);
const backButton = screen.getByText("Back");
await userEvent.click(backButton);
expect(getUpdatedTtc).toHaveBeenCalled();
expect(onBack).toHaveBeenCalled();
});
test("back button is not rendered when isFirstQuestion is true", () => {
render(<ConsentQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("back button is not rendered when isBackButtonHidden is true", () => {
render(<ConsentQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("handles keyboard space press on label", () => {
render(<ConsentQuestion {...defaultProps} />);
const label = screen.getByText("I agree to the terms").closest("label");
fireEvent.keyDown(label!, { key: " " });
expect(defaultProps.onChange).toHaveBeenCalledWith({ "consent-q": "accepted" });
});
});

View File

@@ -0,0 +1,267 @@
import { getUpdatedTtc } from "@/lib/ttc";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyContactInfoQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ContactInfoQuestion } from "./contact-info-question";
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi.fn().mockImplementation((obj, lang) => obj[lang] ?? obj.default ?? ""),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn().mockReturnValue({}),
useTtc: vi.fn(),
}));
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({ onClick, backButtonLabel }: { onClick: () => void; backButtonLabel: string }) => (
<button onClick={onClick} data-testid="back-button">
{backButtonLabel}
</button>
),
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel }: { buttonLabel: string }) => (
<button type="submit" data-testid="submit-button">
{buttonLabel}
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline }: { headline: string }) => <h1 data-testid="headline">{headline}</h1>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: { subheader: string }) => <p data-testid="subheader">{subheader}</p>,
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: ({ imgUrl, videoUrl }: { imgUrl?: string; videoUrl?: string }) => (
<div data-testid="question-media" data-img={imgUrl} data-video={videoUrl} />
),
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="scrollable-container">{children}</div>
),
}));
describe("ContactInfoQuestion", () => {
afterEach(() => {
cleanup();
});
const mockQuestion: TSurveyContactInfoQuestion = {
id: "contact-info-q",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: {
default: "Contact Information",
en: "Contact Information",
},
subheader: {
default: "Please provide your contact info",
en: "Please provide your contact info",
},
required: true,
buttonLabel: {
default: "Next",
en: "Next",
},
backButtonLabel: {
default: "Back",
en: "Back",
},
imageUrl: "test-image-url",
firstName: {
show: true,
required: true,
placeholder: {
default: "First Name",
en: "First Name",
},
},
lastName: {
show: true,
required: false,
placeholder: {
default: "Last Name",
en: "Last Name",
},
},
email: {
show: true,
required: true,
placeholder: {
default: "Email",
en: "Email",
},
},
phone: {
show: false,
required: false,
placeholder: {
default: "Phone",
en: "Phone",
},
},
company: {
show: false,
required: false,
placeholder: {
default: "Company",
en: "Company",
},
},
};
const defaultProps = {
question: mockQuestion,
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
currentQuestionId: "contact-info-q",
autoFocusEnabled: true,
isBackButtonHidden: false,
};
test("renders contact info question correctly", () => {
render(<ContactInfoQuestion {...defaultProps} />);
expect(screen.getByTestId("headline")).toHaveTextContent("Contact Information");
expect(screen.getByTestId("subheader")).toHaveTextContent("Please provide your contact info");
expect(screen.getByTestId("question-media")).toBeInTheDocument();
expect(screen.getByLabelText("First Name*")).toBeInTheDocument();
expect(screen.getByLabelText("Last Name")).toBeInTheDocument();
expect(screen.getByLabelText("Email*")).toBeInTheDocument();
expect(screen.queryByLabelText("Phone")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Company")).not.toBeInTheDocument();
});
test("handles input changes correctly", async () => {
const user = userEvent.setup();
render(<ContactInfoQuestion {...defaultProps} />);
const firstNameInput = screen.getByLabelText("First Name*");
await user.type(firstNameInput, "John");
expect(defaultProps.onChange).toHaveBeenCalledWith({
"contact-info-q": ["John", "", "", "", ""],
});
const emailInput = screen.getByLabelText("Email*");
await user.type(emailInput, "john@example.com");
expect(defaultProps.onChange).toHaveBeenCalledWith({
"contact-info-q": ["", "", "john@example.com", "", ""],
});
});
test("handles form submission with values", async () => {
const user = userEvent.setup();
vi.mocked(getUpdatedTtc).mockReturnValueOnce({ "contact-info-q": 100 });
render(<ContactInfoQuestion {...defaultProps} value={["John", "Doe", "john@example.com", "", ""]} />);
const submitButton = screen.getByTestId("submit-button");
await user.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalledWith(
{ "contact-info-q": ["John", "Doe", "john@example.com", "", ""] },
{ "contact-info-q": 100 }
);
});
test("handles form submission with empty values", async () => {
const user = userEvent.setup();
vi.mocked(getUpdatedTtc).mockReturnValueOnce({ "contact-info-q": 100 });
const onSubmitMock = vi.fn();
const { container } = render(
<ContactInfoQuestion {...defaultProps} value={["", "", "", "", ""]} onSubmit={onSubmitMock} />
);
// Get the form element and submit it directly
const form = container.querySelector("form");
expect(form).not.toBeNull();
// Trigger the submit event directly on the form
await user.click(screen.getByTestId("submit-button"));
// Manually trigger the form submission event as a fallback
if (form && onSubmitMock.mock.calls.length === 0) {
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
}
expect(onSubmitMock).toHaveBeenCalledWith({ "contact-info-q": [] }, { "contact-info-q": 100 });
});
test("handles back button click", async () => {
const user = userEvent.setup();
vi.mocked(getUpdatedTtc).mockReturnValueOnce({ "contact-info-q": 100 });
render(<ContactInfoQuestion {...defaultProps} />);
const backButton = screen.getByTestId("back-button");
await user.click(backButton);
expect(defaultProps.onBack).toHaveBeenCalled();
expect(defaultProps.setTtc).toHaveBeenCalledWith({ "contact-info-q": 100 });
});
test("hides back button when isFirstQuestion is true", () => {
render(<ContactInfoQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("hides back button when isBackButtonHidden is true", () => {
render(<ContactInfoQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("renders without media when not available", () => {
const questionWithoutMedia = {
...mockQuestion,
imageUrl: undefined,
videoUrl: undefined,
};
render(<ContactInfoQuestion {...defaultProps} question={questionWithoutMedia} />);
expect(screen.queryByTestId("question-media")).not.toBeInTheDocument();
});
test("handles different field types correctly", () => {
const questionWithAllFields = {
...mockQuestion,
phone: {
...mockQuestion.phone,
show: true,
},
company: {
...mockQuestion.company,
show: true,
},
};
render(<ContactInfoQuestion {...defaultProps} question={questionWithAllFields} />);
expect(screen.getByLabelText("First Name*")).toHaveAttribute("type", "text");
expect(screen.getByLabelText("Last Name")).toHaveAttribute("type", "text");
expect(screen.getByLabelText("Email*")).toHaveAttribute("type", "email");
expect(screen.getByLabelText("Phone")).toHaveAttribute("type", "number");
expect(screen.getByLabelText("Company")).toHaveAttribute("type", "text");
});
});

View File

@@ -159,8 +159,9 @@ export function ContactInfoQuestion({
return (
field.show && (
<div className="fb-space-y-1">
<Label text={isFieldRequired() ? `${field.label}*` : field.label} />
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
<Input
id={field.id}
ref={index === 0 ? contactInfoRef : null}
key={field.id}
required={isFieldRequired()}

View File

@@ -0,0 +1,199 @@
import { getUpdatedTtc } from "@/lib/ttc";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyCTAQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { CTAQuestion } from "./cta-question";
// Mock dependencies
vi.mock("@/components/buttons/back-button", () => ({
BackButton: vi.fn(({ onClick, backButtonLabel, tabIndex }) => (
<button onClick={onClick} data-testid="back-button" tabIndex={tabIndex}>
{backButtonLabel || "Back"}
</button>
)),
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: vi.fn(({ onClick, buttonLabel, tabIndex }) => (
<button onClick={onClick} data-testid="submit-button" tabIndex={tabIndex}>
{buttonLabel || "Submit"}
</button>
)),
}));
vi.mock("@/components/general/headline", () => ({
Headline: vi.fn(({ headline }) => <div data-testid="headline">{headline}</div>),
}));
vi.mock("@/components/general/html-body", () => ({
HtmlBody: vi.fn(({ htmlString }) => <div data-testid="html-body">{htmlString}</div>),
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: vi.fn(() => <div data-testid="question-media">Media</div>),
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: vi.fn(({ children }) => <div data-testid="scrollable-container">{children}</div>),
}));
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi.fn((value) => value),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn(() => ({})),
useTtc: vi.fn(),
}));
describe("CTAQuestion", () => {
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
const mockQuestion: TSurveyCTAQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "Test Headline" },
html: { default: "Test HTML content" },
buttonLabel: { default: "Click Me" },
dismissButtonLabel: { default: "Skip This" },
backButtonLabel: { default: "Go Back" },
required: false,
buttonExternal: false,
buttonUrl: "",
};
const mockProps = {
question: mockQuestion,
value: "",
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
test("renders correctly without media", () => {
render(<CTAQuestion {...mockProps} />);
expect(screen.getByTestId("headline")).toBeInTheDocument();
expect(screen.getByTestId("html-body")).toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.queryByTestId("question-media")).not.toBeInTheDocument();
});
test("renders correctly with image media", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(<CTAQuestion {...mockProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("renders correctly with video media", () => {
const questionWithMedia = {
...mockQuestion,
videoUrl: "https://example.com/video.mp4",
};
render(<CTAQuestion {...mockProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("does not show back button when isFirstQuestion is true", () => {
render(<CTAQuestion {...mockProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("does not show back button when isBackButtonHidden is true", () => {
render(<CTAQuestion {...mockProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("calls onSubmit and onChange when submit button is clicked", async () => {
const user = userEvent.setup();
render(<CTAQuestion {...mockProps} />);
await user.click(screen.getByTestId("submit-button"));
expect(mockProps.onSubmit).toHaveBeenCalledWith({ q1: "clicked" }, {});
expect(mockProps.onChange).toHaveBeenCalledWith({ q1: "clicked" });
});
test("calls onBack when back button is clicked", async () => {
const user = userEvent.setup();
render(<CTAQuestion {...mockProps} />);
await user.click(screen.getByTestId("back-button"));
expect(mockProps.onBack).toHaveBeenCalled();
expect(vi.mocked(getUpdatedTtc)).toHaveBeenCalled();
expect(mockProps.setTtc).toHaveBeenCalled();
});
test("does not show skip button when question is required", () => {
const requiredQuestion = {
...mockQuestion,
required: true,
};
render(<CTAQuestion {...mockProps} question={requiredQuestion} />);
// There should only be 2 buttons (submit and back) when required is true
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBe(2);
});
test("opens external URL when buttonExternal is true", async () => {
const mockOpenExternalURL = vi.fn();
const externalQuestion = {
...mockQuestion,
buttonExternal: true,
buttonUrl: "https://example.com",
};
const user = userEvent.setup();
render(
<CTAQuestion {...mockProps} question={externalQuestion} onOpenExternalURL={mockOpenExternalURL} />
);
await user.click(screen.getByTestId("submit-button"));
expect(mockOpenExternalURL).toHaveBeenCalledWith("https://example.com");
});
test("falls back to window.open when onOpenExternalURL is not provided", async () => {
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => {
return { focus: vi.fn() } as unknown as Window;
});
const externalQuestion = {
...mockQuestion,
buttonExternal: true,
buttonUrl: "https://example.com",
};
const user = userEvent.setup();
render(<CTAQuestion {...mockProps} question={externalQuestion} />);
await user.click(screen.getByTestId("submit-button"));
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com", "_blank");
windowOpenSpy.mockRestore();
});
test("sets tab index correctly when isCurrent is true", () => {
render(<CTAQuestion {...mockProps} currentQuestionId="q1" />);
expect(screen.getByTestId("submit-button")).toHaveAttribute("tabindex", "0");
expect(screen.getByTestId("back-button")).toHaveAttribute("tabindex", "0");
});
test("sets tab index to -1 when isCurrent is false", () => {
render(<CTAQuestion {...mockProps} currentQuestionId="q2" />);
expect(screen.getByTestId("submit-button")).toHaveAttribute("tabindex", "-1");
expect(screen.getByTestId("back-button")).toHaveAttribute("tabindex", "-1");
});
});

View File

@@ -0,0 +1,149 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { DateQuestion } from "./date-question";
// Mock react-date-picker
vi.mock("react-date-picker", () => ({
default: vi.fn(({ onChange, value }) => (
<div data-testid="date-picker-mock">
<button data-testid="date-select-button" onClick={() => onChange(new Date("2023-01-15"))}>
Select Date
</button>
<span>{value ? value.toISOString() : "No date selected"}</span>
</div>
)),
}));
// Mock dependencies
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: vi.fn().mockReturnValue({ mockUpdatedTtc: true }),
}));
describe("DateQuestion", () => {
afterEach(() => {
cleanup();
});
const mockQuestion = {
id: "date-question-1",
type: TSurveyQuestionTypeEnum.Date,
headline: { default: "Select a date" },
subheader: { default: "Please choose a date" },
required: true,
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
};
const defaultProps = {
question: mockQuestion,
value: "",
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "date-question-1",
isBackButtonHidden: false,
} as any;
test("renders date question correctly", () => {
render(<DateQuestion {...defaultProps} />);
expect(screen.getAllByText("Select a date")[0]).toBeInTheDocument();
expect(screen.getByText("Please choose a date")).toBeInTheDocument();
expect(screen.getByText("Next")).toBeInTheDocument();
expect(screen.getByText("Back")).toBeInTheDocument();
expect(screen.getByText("Select a date", { selector: "span" })).toBeInTheDocument();
});
test("displays error message when form is submitted without a date if required", async () => {
const user = userEvent.setup();
render(<DateQuestion {...defaultProps} />);
await user.click(screen.getByText("Next"));
expect(screen.getByText("Please select a date.")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
test("calls onSubmit when form is submitted with a valid date", async () => {
const user = userEvent.setup();
const testDate = "2023-01-15";
const props = { ...defaultProps, value: testDate };
render(<DateQuestion {...props} />);
await user.click(screen.getByText("Next"));
expect(props.onSubmit).toHaveBeenCalledWith({ "date-question-1": testDate }, expect.anything());
});
test("calls onBack when back button is clicked", async () => {
const user = userEvent.setup();
render(<DateQuestion {...defaultProps} />);
await user.click(screen.getByText("Back"));
expect(defaultProps.onBack).toHaveBeenCalledTimes(1);
expect(defaultProps.setTtc).toHaveBeenCalledTimes(2);
});
test("does not render back button when isFirstQuestion is true", () => {
render(<DateQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("does not render back button when isBackButtonHidden is true", () => {
render(<DateQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("renders media content when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(<DateQuestion {...defaultProps} question={questionWithMedia} />);
// Media component should be rendered (implementation detail check)
expect(screen.getAllByText("Select a date")[0]).toBeInTheDocument();
});
test("opens date picker when button is clicked", async () => {
const user = userEvent.setup();
render(<DateQuestion {...defaultProps} />);
// Click the select date button
const dateButton = screen.getByRole("button", { name: /select a date/i });
await user.click(dateButton);
// We can check for our mocked date picker
expect(screen.getByTestId("date-picker-mock")).toBeInTheDocument();
});
test("displays formatted date when a date is selected", async () => {
const dateValue = "2023-01-15";
const props = { ...defaultProps, value: dateValue };
render(<DateQuestion {...props} />);
// Handle timezone differences by allowing either 14th or 15th
const dateRegex = /(14th|15th) of January, 2023/;
const dateElement = screen.getByText(dateRegex);
expect(dateElement).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,235 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyFileUploadQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { FileUploadQuestion } from "./file-upload-question";
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel, tabIndex }: any) => (
<button data-testid="submit-button" tabIndex={tabIndex}>
{buttonLabel}
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline, required }: any) => (
<h2 data-testid="headline" data-required={required}>
{headline}
</h2>
),
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: any) => <p data-testid="subheader">{subheader}</p>,
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: ({ imgUrl, videoUrl }: any) => (
<div data-testid="question-media" data-img-url={imgUrl} data-video-url={videoUrl}></div>
),
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: any) => <div data-testid="scrollable-container">{children}</div>,
}));
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({ backButtonLabel, onClick, tabIndex }: any) => (
<button data-testid="back-button" onClick={onClick} tabIndex={tabIndex}>
{backButtonLabel}
</button>
),
}));
vi.mock("@/components/general/file-input", () => ({
FileInput: ({ onUploadCallback, fileUrls }: any) => (
<div data-testid="file-input">
<button data-testid="upload-button" onClick={() => onUploadCallback(["file-url-1"])}>
Upload
</button>
<div data-testid="file-urls">{fileUrls ? fileUrls.join(",") : ""}</div>
</div>
),
}));
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: (value: any, language?: string) => {
if (typeof value === "string") return value;
if (value?.default) return value.default;
return value?.[language ?? "en"] ?? "";
},
}));
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: (ttc: any, id: string) => ({ ...ttc, [id]: 1000 }),
}));
// Mock window.alert before tests
Object.defineProperty(window, "alert", {
writable: true,
value: vi.fn(),
});
describe("FileUploadQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockQuestion: TSurveyFileUploadQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.FileUpload,
headline: { default: "Upload your file" },
subheader: { default: "Please upload a relevant file" },
required: true,
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Back" },
allowMultipleFiles: false,
};
const defaultProps = {
question: mockQuestion,
value: [],
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
onFileUpload: vi.fn().mockResolvedValue("uploaded-file-url"),
isFirstQuestion: false,
isLastQuestion: false,
surveyId: "survey123",
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: true,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
test("renders correctly with all elements", () => {
render(<FileUploadQuestion {...defaultProps} />);
expect(screen.getByTestId("headline")).toHaveTextContent("Upload your file");
expect(screen.getByTestId("subheader")).toHaveTextContent("Please upload a relevant file");
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
expect(screen.getByTestId("file-input")).toBeInTheDocument();
});
test("renders with media when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "image-url.jpg",
};
render(<FileUploadQuestion {...defaultProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
expect(screen.getByTestId("question-media")).toHaveAttribute("data-img-url", "image-url.jpg");
});
test("does not render media when not available", () => {
render(<FileUploadQuestion {...defaultProps} />);
expect(screen.queryByTestId("question-media")).not.toBeInTheDocument();
});
test("hides back button when isFirstQuestion is true", () => {
render(<FileUploadQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("hides back button when isBackButtonHidden is true", () => {
render(<FileUploadQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("calls onBack when back button is clicked", async () => {
const onBackMock = vi.fn();
render(<FileUploadQuestion {...defaultProps} onBack={onBackMock} />);
await userEvent.click(screen.getByTestId("back-button"));
expect(onBackMock).toHaveBeenCalledTimes(1);
});
test("calls onChange when file is uploaded", async () => {
const onChangeMock = vi.fn();
render(<FileUploadQuestion {...defaultProps} onChange={onChangeMock} />);
await userEvent.click(screen.getByTestId("upload-button"));
expect(onChangeMock).toHaveBeenCalledWith({ q1: ["file-url-1"] });
});
test("calls onSubmit with value when form is submitted with valid data", () => {
const onSubmitMock = vi.fn();
const setTtcMock = vi.fn();
global.performance.now = vi.fn().mockReturnValue(1000);
const { container } = render(
<FileUploadQuestion
{...defaultProps}
onSubmit={onSubmitMock}
setTtc={setTtcMock}
value={["file-url-1"]}
/>
);
const form = container.querySelector("form");
fireEvent.submit(form as HTMLFormElement);
expect(setTtcMock).toHaveBeenCalled();
expect(onSubmitMock).toHaveBeenCalledWith({ q1: ["file-url-1"] }, expect.any(Object));
});
test("shows alert when submitting without a file for required question", () => {
const onSubmitMock = vi.fn();
const { container } = render(<FileUploadQuestion {...defaultProps} onSubmit={onSubmitMock} value={[]} />);
const form = container.querySelector("form");
fireEvent.submit(form as HTMLFormElement);
expect(window.alert).toHaveBeenCalledWith("Please upload a file");
expect(onSubmitMock).not.toHaveBeenCalled();
});
test("submits with empty array when question is not required and no file provided", () => {
const onSubmitMock = vi.fn();
const questionNotRequired = { ...mockQuestion, required: false };
const { container } = render(
<FileUploadQuestion
{...defaultProps}
onSubmit={onSubmitMock}
question={questionNotRequired}
value={[]}
/>
);
const form = container.querySelector("form");
fireEvent.submit(form as HTMLFormElement);
expect(onSubmitMock).toHaveBeenCalledWith({ q1: [] }, { q1: 1000 });
});
test("sets tabIndex correctly based on current question", () => {
render(<FileUploadQuestion {...defaultProps} currentQuestionId="q1" />);
expect(screen.getByTestId("submit-button")).toHaveAttribute("tabIndex", "0");
expect(screen.getByTestId("back-button")).toHaveAttribute("tabIndex", "0");
cleanup();
render(<FileUploadQuestion {...defaultProps} currentQuestionId="different-id" />);
expect(screen.getByTestId("submit-button")).toHaveAttribute("tabIndex", "-1");
expect(screen.getByTestId("back-button")).toHaveAttribute("tabIndex", "-1");
});
});

View File

@@ -0,0 +1,211 @@
import { getShuffledRowIndices } from "@/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyMatrixQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { MatrixQuestion } from "./matrix-question";
// Mock dependencies
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi.fn((value, languageCode) => {
if (typeof value === "string") return value;
return value[languageCode] ?? value.default ?? "";
}),
}));
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: vi.fn((ttc) => ttc),
}));
// Fix the utils mock to handle all exports
vi.mock("@/lib/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/utils")>();
return {
...(actual as Record<string, unknown>),
getShuffledRowIndices: vi.fn((length) => Array.from({ length }, (_, i) => i)),
};
});
// Mock components that might make tests more complex
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: ({ imgUrl, videoUrl }: { imgUrl?: string; videoUrl?: string }) =>
imgUrl ? <img src={imgUrl} alt="Question media" /> : videoUrl ? <video src={videoUrl}></video> : null, // NOSONAR
}));
describe("MatrixQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const defaultProps = {
question: {
id: "matrix-q1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate our services" },
subheader: { default: "Please rate the following services" },
required: true,
shuffleOption: "none",
rows: ["Service 1", "Service 2", "Service 3"],
columns: ["Poor", "Fair", "Good", "Excellent"],
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
imageUrl: "",
videoUrl: "",
} as unknown as TSurveyMatrixQuestion,
value: {},
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "default",
ttc: {},
setTtc: vi.fn(),
currentQuestionId: "matrix-q1",
isBackButtonHidden: false,
};
test("renders matrix question with correct rows and columns", () => {
render(<MatrixQuestion {...defaultProps} />);
expect(screen.getByText("Rate our services")).toBeInTheDocument();
expect(screen.getByText("Please rate the following services")).toBeInTheDocument();
expect(screen.getByText("Service 1")).toBeInTheDocument();
expect(screen.getByText("Service 2")).toBeInTheDocument();
expect(screen.getByText("Service 3")).toBeInTheDocument();
expect(screen.getByText("Poor")).toBeInTheDocument();
expect(screen.getByText("Fair")).toBeInTheDocument();
expect(screen.getByText("Good")).toBeInTheDocument();
expect(screen.getByText("Excellent")).toBeInTheDocument();
expect(screen.getByText("Next")).toBeInTheDocument();
expect(screen.getByText("Back")).toBeInTheDocument();
});
test("hides back button when isFirstQuestion is true", () => {
render(<MatrixQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("hides back button when isBackButtonHidden is true", () => {
render(<MatrixQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("selects and deselects a radio button on click", async () => {
const user = userEvent.setup();
render(<MatrixQuestion {...defaultProps} />);
// Find the first radio input cell by finding the intersection of row and column
const firstRow = screen.getByText("Service 1").closest("tr");
const firstCell = firstRow?.querySelector("td:first-of-type");
expect(firstCell).toBeInTheDocument();
await user.click(firstCell!);
expect(defaultProps.onChange).toHaveBeenCalled();
// Select the same option again should deselect it
await user.click(firstCell!);
expect(defaultProps.onChange).toHaveBeenCalledTimes(2);
});
test("selects a radio button with keyboard navigation", async () => {
const user = userEvent.setup();
render(<MatrixQuestion {...defaultProps} />);
// Find a specific row and a cell in that row
const firstRow = screen.getByText("Service 1").closest("tr");
// Get the third cell (which would be the "Good" column)
const goodCell = firstRow?.querySelectorAll("td")[2];
expect(goodCell).toBeInTheDocument();
goodCell?.focus();
await user.keyboard(" "); // Press space
expect(defaultProps.onChange).toHaveBeenCalled();
});
test("submits the form with selected values", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const { container } = render(
<MatrixQuestion
{...defaultProps}
onSubmit={onSubmit}
value={{ "Service 1": "Good", "Service 2": "Excellent" }}
/>
);
// Find the form element and submit it directly
const form = container.querySelector("form");
expect(form).toBeInTheDocument();
// Use fireEvent instead of userEvent for form submission
await user.click(screen.getByText("Next"));
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
expect(onSubmit).toHaveBeenCalledWith(
{ "matrix-q1": { "Service 1": "Good", "Service 2": "Excellent" } },
{}
);
});
test("calls onBack when back button is clicked", async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(<MatrixQuestion {...defaultProps} onBack={onBack} />);
const backButton = screen.getByText("Back");
await user.click(backButton);
expect(onBack).toHaveBeenCalled();
});
test("renders media when available", () => {
const question = {
...defaultProps.question,
imageUrl: "https://example.com/image.jpg",
} as unknown as TSurveyMatrixQuestion;
render(<MatrixQuestion {...defaultProps} question={question} />);
// QuestionMedia component should be rendered
const questionMediaContainer = document.querySelector("img");
expect(questionMediaContainer).toBeInTheDocument();
});
test("shuffles rows when shuffleOption is not 'none'", () => {
const question = {
...defaultProps.question,
shuffleOption: "all",
} as unknown as TSurveyMatrixQuestion;
render(<MatrixQuestion {...defaultProps} question={question} />);
expect(getShuffledRowIndices).toHaveBeenCalled();
});
test("initializes empty values correctly when selecting first option", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MatrixQuestion {...defaultProps} value={{}} onChange={onChange} />);
// Find the first row and its first cell
const firstRow = screen.getByText("Service 1").closest("tr");
const firstCell = firstRow?.querySelector("td:first-of-type");
expect(firstCell).toBeInTheDocument();
await user.click(firstCell!);
expect(onChange).toHaveBeenCalled();
const expectedValue = expect.objectContaining({
"matrix-q1": expect.any(Object),
});
expect(onChange).toHaveBeenCalledWith(expectedValue);
});
});

View File

@@ -0,0 +1,210 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TSurveyMultipleChoiceQuestion } from "@formbricks/types/surveys/types";
import { MultipleChoiceMultiQuestion } from "./multiple-choice-multi-question";
// Mock components
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({ onClick, backButtonLabel }: { onClick: () => void; backButtonLabel?: string }) => (
<button onClick={onClick} data-testid="back-button">
{backButtonLabel ?? "Back"}
</button>
),
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel }: { buttonLabel?: string }) => (
<button type="submit" data-testid="submit-button">
{buttonLabel ?? "Submit"}
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline }: { headline: string }) => <h1 data-testid="headline">{headline}</h1>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: { subheader: string }) => <p data-testid="subheader">{subheader}</p>,
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media" />,
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="scrollable-container">{children}</div>
),
}));
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: vi.fn(() => ({ questionId: "ttc-value" })),
}));
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: (_value: any, _languageCode: string) => {
if (typeof _value === "string") return _value;
return _value?.["en"] ?? _value?.default ?? "";
},
}));
describe("MultipleChoiceMultiQuestion", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
question: {
id: "q1",
type: "multipleChoiceMulti",
headline: { en: "Test Question" },
subheader: { en: "Select multiple options" },
required: true,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "c2", label: { en: "Option 2" } },
{ id: "c3", label: { en: "Option 3" } },
{ id: "other", label: { en: "Other" } },
],
buttonLabel: { en: "Next" },
backButtonLabel: { en: "Back" },
otherOptionPlaceholder: { en: "Please specify" },
} as TSurveyMultipleChoiceQuestion,
value: [],
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
test("renders the component correctly", () => {
render(<MultipleChoiceMultiQuestion {...defaultProps} />);
expect(screen.getByTestId("headline")).toBeInTheDocument();
expect(screen.getByTestId("subheader")).toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
// Check all options are rendered
expect(screen.getByLabelText("Option 1")).toBeInTheDocument();
expect(screen.getByLabelText("Option 2")).toBeInTheDocument();
expect(screen.getByLabelText("Option 3")).toBeInTheDocument();
expect(screen.getByLabelText("Other")).toBeInTheDocument();
});
test("handles selecting options", async () => {
// Test selecting first option (starting with empty array)
const onChange1 = vi.fn();
const { unmount } = render(
<MultipleChoiceMultiQuestion {...defaultProps} value={[]} onChange={onChange1} />
);
await userEvent.click(screen.getByLabelText("Option 1"));
expect(onChange1).toHaveBeenCalledWith({ q1: ["Option 1"] });
unmount();
// Test selecting second option (already having first option selected)
const onChange2 = vi.fn();
const { unmount: unmount2 } = render(
<MultipleChoiceMultiQuestion {...defaultProps} value={["Option 1"]} onChange={onChange2} />
);
await userEvent.click(screen.getByLabelText("Option 2"));
expect(onChange2).toHaveBeenCalledWith({ q1: ["Option 1", "Option 2"] });
unmount2();
// Test deselecting an option
const onChange3 = vi.fn();
render(
<MultipleChoiceMultiQuestion {...defaultProps} value={["Option 1", "Option 2"]} onChange={onChange3} />
);
await userEvent.click(screen.getByLabelText("Option 1"));
expect(onChange3).toHaveBeenCalledWith({ q1: ["Option 2"] });
});
test("handles 'Other' option correctly", async () => {
const onChange = vi.fn();
render(<MultipleChoiceMultiQuestion {...defaultProps} onChange={onChange} />);
// When clicking Other, it calls onChange with an empty string first
await userEvent.click(screen.getByLabelText("Other"));
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({ q1: [""] });
// Clear the mock to focus on typing behavior
onChange.mockClear();
// Enter text in the field and use fireEvent directly which doesn't trigger onChange for each character
const otherInput = screen.getByPlaceholderText("Please specify");
fireEvent.change(otherInput, { target: { value: "Custom response" } });
expect(onChange).toHaveBeenCalledWith({ q1: ["Custom response"] });
});
test("handles form submission", async () => {
const onSubmit = vi.fn();
const { container } = render(
<MultipleChoiceMultiQuestion {...defaultProps} value={["Option 1"]} onSubmit={onSubmit} />
);
// Get the form directly and submit it
const form = container.querySelector("form");
expect(form).toBeInTheDocument();
fireEvent.submit(form!);
expect(onSubmit).toHaveBeenCalledWith({ q1: ["Option 1"] }, { questionId: "ttc-value" });
});
test("calls onBack when back button is clicked", async () => {
const onBack = vi.fn();
render(<MultipleChoiceMultiQuestion {...defaultProps} onBack={onBack} />);
await userEvent.click(screen.getByTestId("back-button"));
expect(onBack).toHaveBeenCalled();
expect(defaultProps.setTtc).toHaveBeenCalledWith({ questionId: "ttc-value" });
});
test("hides back button when isFirstQuestion is true or isBackButtonHidden is true", () => {
const { rerender } = render(<MultipleChoiceMultiQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
rerender(<MultipleChoiceMultiQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("renders media when available", () => {
const questionWithMedia = {
...defaultProps.question,
imageUrl: "https://example.com/image.jpg",
};
render(<MultipleChoiceMultiQuestion {...defaultProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("handles shuffled choices correctly", () => {
const shuffledQuestion = {
...defaultProps.question,
shuffleOption: "all",
} as TSurveyMultipleChoiceQuestion;
render(<MultipleChoiceMultiQuestion {...defaultProps} question={shuffledQuestion} />);
expect(screen.getByLabelText("Option 1")).toBeInTheDocument();
expect(screen.getByLabelText("Option 2")).toBeInTheDocument();
expect(screen.getByLabelText("Option 3")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,274 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyMultipleChoiceQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { MultipleChoiceSingleQuestion } from "./multiple-choice-single-question";
// Mock components
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({
onClick,
backButtonLabel,
tabIndex,
}: {
onClick: () => void;
backButtonLabel: string;
tabIndex: number;
}) => (
<button data-testid="back-button" onClick={onClick} tabIndex={tabIndex}>
{backButtonLabel}
</button>
),
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel, tabIndex }: { buttonLabel: string; tabIndex: number }) => (
<button data-testid="submit-button" type="submit" tabIndex={tabIndex}>
{buttonLabel}
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline }: { headline: string }) => <h1 data-testid="headline">{headline}</h1>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: { subheader: string }) => <p data-testid="subheader">{subheader}</p>,
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media">Media Content</div>,
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="scrollable-container">{children}</div>
),
}));
// Mock utility functions
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi.fn((obj, lang) => obj[lang] || obj["default"]),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn((ttc, questionId, time) => ({ ...ttc, [questionId]: time })),
useTtc: vi.fn(),
}));
vi.mock("@/lib/utils", () => ({
cn: vi.fn((...args) => args.filter(Boolean).join(" ")),
getShuffledChoicesIds: vi.fn((choices) => choices.map((choice: any) => choice.id)),
}));
// Test data
const mockQuestion: TSurveyMultipleChoiceQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Test Question" },
subheader: { default: "This is a test question" },
required: true,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
{ id: "other", label: { default: "Other" } },
],
shuffleOption: "none",
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
otherOptionPlaceholder: { default: "Please specify" },
};
describe("MultipleChoiceSingleQuestion", () => {
const mockOnChange = vi.fn();
const mockOnSubmit = vi.fn();
const mockOnBack = vi.fn();
const mockSetTtc = vi.fn();
const defaultProps = {
question: mockQuestion,
onChange: mockOnChange,
onSubmit: mockOnSubmit,
onBack: mockOnBack,
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "default",
ttc: {} as TResponseTtc,
setTtc: mockSetTtc,
autoFocusEnabled: false,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, "performance", {
value: { now: vi.fn(() => 1000) },
writable: true,
});
});
afterEach(() => {
cleanup();
});
test("renders the question with choices", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
expect(screen.getByTestId("headline")).toHaveTextContent("Test Question");
expect(screen.getByTestId("subheader")).toHaveTextContent("This is a test question");
expect(screen.getByLabelText("Choice 1")).toBeInTheDocument();
expect(screen.getByLabelText("Choice 2")).toBeInTheDocument();
expect(screen.getByLabelText("Other")).toBeInTheDocument();
});
test("displays media content when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("allows selecting a choice", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("shows input field when 'Other' option is selected", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const otherRadio = screen.getByLabelText("Other");
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
});
test("handles 'other' option input change", async () => {
const user = userEvent.setup();
// Render with initial value to simulate user typing in the other field
render(
<MultipleChoiceSingleQuestion
{...defaultProps}
value="" // Start with empty string
/>
);
// Use getByRole to more specifically target the radio input
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
// Clear mock calls from the initial setup
mockOnChange.mockClear();
// Get the input and simulate change directly
const otherInput = screen.getByPlaceholderText("Please specify");
// Use fireEvent directly for more reliable testing of the onChange handler
(otherInput as any).value = "Custom response";
otherInput.dispatchEvent(new Event("input", { bubbles: true }));
otherInput.dispatchEvent(new Event("change", { bubbles: true }));
// Verify the onChange handler was called with the correct value
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Custom response" });
});
test("submits form with selected value", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="Choice 1" />);
const submitButton = screen.getByTestId("submit-button");
await user.click(submitButton);
expect(mockOnSubmit).toHaveBeenCalledWith({ q1: "Choice 1" }, expect.any(Object));
});
test("calls onBack when back button is clicked", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const backButton = screen.getByTestId("back-button");
await user.click(backButton);
expect(mockOnBack).toHaveBeenCalled();
expect(mockSetTtc).toHaveBeenCalled();
});
test("hides back button when isFirstQuestion or isBackButtonHidden is true", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
cleanup();
render(<MultipleChoiceSingleQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("handles prefilled answer from URL for first question", () => {
// Mock URL parameter properly for URLSearchParams
const searchParams = new URLSearchParams();
searchParams.append("q1", "Choice 1");
Object.defineProperty(window, "location", {
value: {
search: `?${searchParams.toString()}`,
},
writable: true,
});
// We need to make sure the component actually checks for the URL param
// To do this, we'll create a mock URLSearchParams with a spy
const mockGet = vi.fn().mockReturnValue("Choice 1");
const mockURLSearchParams = vi.fn(() => ({
get: mockGet,
}));
global.URLSearchParams = mockURLSearchParams as any;
render(
<MultipleChoiceSingleQuestion
{...defaultProps}
isFirstQuestion={true}
// Ensure value is undefined so the prefill logic runs
value={undefined}
/>
);
// Verify the URLSearchParams was called with the correct search string
expect(mockURLSearchParams).toHaveBeenCalledWith(window.location.search);
// Verify the get method was called with the question id
expect(mockGet).toHaveBeenCalledWith("q1");
});
test("applies accessibility attributes correctly", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toBeInTheDocument();
const radioInputs = screen.getAllByRole("radio");
expect(radioInputs.length).toBe(3); // 2 regular choices + Other
});
test("sets focus correctly when currentQuestionId matches question.id", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} currentQuestionId="q1" />);
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toHaveAttribute("tabIndex", "0");
const backButton = screen.getByTestId("back-button");
expect(backButton).toHaveAttribute("tabIndex", "0");
});
});

View File

@@ -0,0 +1,211 @@
import { getUpdatedTtc } from "@/lib/ttc";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyNPSQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { NPSQuestion } from "./nps-question";
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi.fn().mockImplementation((value) => {
if (typeof value === "string") return value;
return value?.default || "";
}),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn().mockReturnValue({}),
useTtc: vi.fn(),
}));
vi.mock("preact/hooks", async () => {
const actual = await vi.importActual<typeof import("preact/hooks")>("preact/hooks");
return {
...actual,
useState: vi.fn().mockImplementation(actual.useState),
};
});
describe("NPSQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockQuestion: TSurveyNPSQuestion = {
id: "nps-question-1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
isColorCodingEnabled: false,
};
const mockProps = {
question: mockQuestion,
value: undefined,
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: true,
isLastQuestion: false,
languageCode: "en",
ttc: {} as TResponseTtc,
setTtc: vi.fn(),
autoFocusEnabled: true,
currentQuestionId: "nps-question-1",
isBackButtonHidden: false,
};
test("renders NPS question with correct elements", () => {
render(<NPSQuestion {...mockProps} />);
expect(screen.getByText("How likely are you to recommend us?")).toBeInTheDocument();
expect(screen.getByText("Not likely")).toBeInTheDocument();
expect(screen.getByText("Very likely")).toBeInTheDocument();
// Check all 11 NPS options (0-10) are rendered
for (let i = 0; i <= 10; i++) {
expect(screen.getByRole("radio", { name: i.toString() })).toBeInTheDocument();
}
});
test("calls onChange and onSubmit when clicking on an NPS option", async () => {
vi.useFakeTimers();
render(<NPSQuestion {...mockProps} />);
// Click on rating 7
fireEvent.click(screen.getByRole("radio", { name: "7" }));
expect(mockProps.onChange).toHaveBeenCalledWith({ [mockQuestion.id]: 7 });
expect(getUpdatedTtc).toHaveBeenCalled();
expect(mockProps.setTtc).toHaveBeenCalled();
// Advance timers to trigger the setTimeout callback
vi.advanceTimersByTime(300);
expect(mockProps.onSubmit).toHaveBeenCalledWith({ [mockQuestion.id]: 7 }, {});
vi.useRealTimers();
});
test("renders with color coding when enabled", () => {
const colorCodedProps = {
...mockProps,
question: {
...mockQuestion,
isColorCodingEnabled: true,
},
};
const { container } = render(<NPSQuestion {...colorCodedProps} />);
// Find the fieldset that contains the NPS options
const fieldset = container.querySelector("fieldset");
expect(fieldset).toBeInTheDocument();
// Get only the labels within the NPS options fieldset
const npsLabels = fieldset?.querySelectorAll("label");
expect(npsLabels?.length).toBe(11);
// Verify each NPS label has a color coding div when enabled
let colorDivCount = 0;
npsLabels?.forEach((label) => {
if (label.firstElementChild?.classList.contains("fb-absolute")) {
colorDivCount++;
}
});
expect(colorDivCount).toBe(11);
// Check at least one has the emerald color class for higher ratings
const lastLabel = npsLabels?.[10];
const colorDiv = lastLabel?.firstElementChild;
expect(colorDiv?.classList.contains("fb-bg-emerald-100")).toBe(true);
});
test("renders back button when not first question", () => {
render(<NPSQuestion {...mockProps} isFirstQuestion={false} />);
const backButton = screen.getByText("Back");
expect(backButton).toBeInTheDocument();
fireEvent.click(backButton);
expect(mockProps.onBack).toHaveBeenCalled();
expect(getUpdatedTtc).toHaveBeenCalled();
});
test("doesn't render back button when isBackButtonHidden is true", () => {
render(<NPSQuestion {...mockProps} isFirstQuestion={false} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("handles form submission for non-required questions", async () => {
const nonRequiredProps = {
...mockProps,
question: {
...mockQuestion,
required: false,
},
};
render(<NPSQuestion {...nonRequiredProps} />);
// Submit button should be visible for non-required questions
const submitButton = screen.getByText("Next");
expect(submitButton).toBeInTheDocument();
await userEvent.click(submitButton);
expect(mockProps.onSubmit).toHaveBeenCalled();
expect(getUpdatedTtc).toHaveBeenCalled();
});
test("updates hover state when mouse moves over options", () => {
render(<NPSQuestion {...mockProps} />);
const option = screen.getByText("5").closest("label");
expect(option).toBeInTheDocument();
fireEvent.mouseOver(option!);
expect(option).toHaveClass("fb-bg-accent-bg");
fireEvent.mouseLeave(option!);
expect(option).not.toHaveClass("fb-bg-accent-bg");
});
test("supports keyboard navigation", () => {
render(<NPSQuestion {...mockProps} />);
const option = screen.getByText("5").closest("label");
expect(option).toBeInTheDocument();
// Test spacebar press
fireEvent.keyDown(option!, { key: " " });
expect(mockProps.onChange).toHaveBeenCalled();
});
test("renders media when available", () => {
const propsWithMedia = {
...mockProps,
question: {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
},
};
const { container } = render(<NPSQuestion {...propsWithMedia} />);
// Check if QuestionMedia component is rendered
// Since we're not mocking the QuestionMedia component, we can just verify it's being included
const mediaContainer = container.querySelector(".fb-my-4");
expect(mediaContainer).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,166 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OpenTextQuestion } from "./open-text-question";
// Mock the components that render headline and subheader
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline }: any) => <div data-testid="mock-headline">{headline}</div>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: any) => <div data-testid="mock-subheader">{subheader}</div>,
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn().mockReturnValue({}),
useTtc: vi.fn(),
}));
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi
.fn()
.mockImplementation((value) => (typeof value === "string" ? value : (value.en ?? value.default))),
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media">Media Component</div>,
}));
describe("OpenTextQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const defaultQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Your feedback" },
subheader: { default: "Please share your thoughts" },
inputType: "text",
placeholder: { default: "Type here..." },
required: true,
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Back" },
longAnswer: false,
} as unknown as TSurveyOpenTextQuestion;
const defaultProps = {
question: defaultQuestion,
value: "",
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: true,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
test("renders question with headline and subheader", () => {
render(<OpenTextQuestion {...defaultProps} />);
expect(screen.getByTestId("mock-headline")).toHaveTextContent("Your feedback");
expect(screen.getByTestId("mock-subheader")).toHaveTextContent("Please share your thoughts");
});
test("handles input change for text field", async () => {
const onChange = vi.fn();
render(<OpenTextQuestion {...defaultProps} onChange={onChange} />);
const input = screen.getByPlaceholderText("Type here...");
// Directly set the input value and trigger the input event
Object.defineProperty(input, "value", { value: "Hello" });
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onChange).toHaveBeenCalledWith({ q1: "Hello" });
});
test("submits form with entered value", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setTtc = vi.fn();
render(<OpenTextQuestion {...defaultProps} value="My feedback" onSubmit={onSubmit} setTtc={setTtc} />);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({ q1: "My feedback" }, {});
expect(setTtc).toHaveBeenCalled();
});
test("displays back button when not first question", () => {
render(<OpenTextQuestion {...defaultProps} isFirstQuestion={false} />);
expect(screen.getByText("Back")).toBeInTheDocument();
});
test("hides back button when isBackButtonHidden is true", () => {
render(<OpenTextQuestion {...defaultProps} isFirstQuestion={false} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("calls onBack when back button is clicked", async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const setTtc = vi.fn();
render(<OpenTextQuestion {...defaultProps} isFirstQuestion={false} onBack={onBack} setTtc={setTtc} />);
const backButton = screen.getByText("Back");
await user.click(backButton);
expect(onBack).toHaveBeenCalled();
expect(setTtc).toHaveBeenCalled();
});
test("renders textarea for long answers", () => {
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, longAnswer: true }} />);
expect(screen.getByRole("textbox")).toHaveAttribute("rows", "3");
});
test("displays character limit when configured", () => {
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, charLimit: { max: 100 } }} />);
expect(screen.getByText("0/100")).toBeInTheDocument();
});
test("renders with media when available", () => {
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, imageUrl: "test.jpg" }} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("applies input validation for phone type", () => {
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, inputType: "phone" }} />);
const input = screen.getByPlaceholderText("Type here...");
expect(input).toHaveAttribute("pattern", "^[0-9+][0-9+\\- ]*[0-9]$");
expect(input).toHaveAttribute("title", "Enter a valid phone number");
});
test("applies correct attributes for required fields", () => {
render(<OpenTextQuestion {...defaultProps} />);
const input = screen.getByPlaceholderText("Type here...");
expect(input).toBeRequired();
});
test("auto focuses input when enabled and is current question", () => {
const focusMock = vi.fn();
// Mock the ref implementation for this test
window.HTMLElement.prototype.focus = focusMock;
render(<OpenTextQuestion {...defaultProps} autoFocusEnabled={true} currentQuestionId="q1" />);
expect(focusMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,213 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
type TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { PictureSelectionQuestion } from "./picture-selection-question";
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: vi.fn((value) => (typeof value === "string" ? value : value.default)),
}));
vi.mock("@/lib/storage", () => ({
getOriginalFileNameFromUrl: vi.fn(() => "test-image"),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn((ttc) => ttc),
useTtc: vi.fn(),
}));
describe("PictureSelectionQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockQuestion: TSurveyPictureSelectionQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Select an image" },
required: true,
allowMulti: false,
choices: [
{ id: "c1", imageUrl: "https://example.com/image1.jpg" },
{ id: "c2", imageUrl: "https://example.com/image2.jpg" },
],
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
};
const mockProps = {
question: mockQuestion,
value: [],
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: true,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
test("renders the component correctly", () => {
render(<PictureSelectionQuestion {...mockProps} />);
// Check for images and buttons which are clearly visible in the DOM
expect(screen.getAllByRole("img")).toHaveLength(2);
expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
});
test("renders media content when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/question-image.jpg",
};
render(<PictureSelectionQuestion {...mockProps} question={questionWithMedia} />);
// Check for the QuestionMedia component (additional img would be present)
expect(screen.getAllByRole("img").length).toBeGreaterThan(2);
});
test("handles single selection correctly", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
// Render with custom onChange to track calls more precisely
render(<PictureSelectionQuestion {...mockProps} onChange={onChange} />);
const images = screen.getAllByRole("img");
// First click should select the item
await user.click(images[0]);
expect(onChange).toHaveBeenLastCalledWith({ q1: ["c1"] });
// Reset the mock to clearly see the next call
onChange.mockClear();
// Re-render with the updated value to reflect the current state
cleanup();
render(<PictureSelectionQuestion {...mockProps} value={["c1"]} onChange={onChange} />);
// Click the same image again - should now deselect
const updatedImages = screen.getAllByRole("img");
await user.click(updatedImages[0]);
expect(onChange).toHaveBeenCalledWith({ q1: [] });
});
test("handles multiple selection when allowMulti is true", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const multiQuestion = { ...mockQuestion, allowMulti: true };
render(<PictureSelectionQuestion {...mockProps} question={multiQuestion} onChange={onChange} />);
const images = screen.getAllByRole("img");
// First click selects the first item
await user.click(images[0]);
expect(onChange).toHaveBeenLastCalledWith({ q1: ["c1"] });
// Now we need to re-render with the updated value to simulate state update
onChange.mockClear();
cleanup();
render(
<PictureSelectionQuestion {...mockProps} question={multiQuestion} onChange={onChange} value={["c1"]} />
);
// Click the second image to add it to selection
const updatedImages = screen.getAllByRole("img");
await user.click(updatedImages[1]);
// Now it should add c2 to the existing array with c1
expect(onChange).toHaveBeenCalledWith({ q1: ["c1", "c2"] });
});
test("handles form submission", async () => {
const user = userEvent.setup();
const mockValue = ["c1"];
const mockTtc = { q1: 1000 };
render(<PictureSelectionQuestion {...mockProps} value={mockValue} ttc={mockTtc} />);
const submitButton = screen.getByText("Next");
await user.click(submitButton);
expect(mockProps.onSubmit).toHaveBeenCalledWith({ q1: ["c1"] }, mockTtc);
});
test("handles back button click", async () => {
const user = userEvent.setup();
render(<PictureSelectionQuestion {...mockProps} />);
const backButton = screen.getByText("Back");
await user.click(backButton);
expect(mockProps.onBack).toHaveBeenCalled();
});
test("doesn't render back button when isFirstQuestion or isBackButtonHidden is true", () => {
render(<PictureSelectionQuestion {...mockProps} isFirstQuestion={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
cleanup();
render(<PictureSelectionQuestion {...mockProps} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
});
test("handles keyboard navigation with Space key", () => {
render(<PictureSelectionQuestion {...mockProps} />);
const images = screen.getAllByRole("img");
const label = images[0].closest("label");
fireEvent.keyDown(label!, { key: " " });
expect(mockProps.onChange).toHaveBeenCalledWith({ q1: ["c1"] });
});
test("renders checkboxes when allowMulti is true", () => {
const multiQuestion = { ...mockQuestion, allowMulti: true };
render(<PictureSelectionQuestion {...mockProps} question={multiQuestion} value={["c1"]} />);
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).not.toBeChecked();
});
test("renders radio buttons when allowMulti is false", () => {
render(<PictureSelectionQuestion {...mockProps} value={["c1"]} />);
const radioButtons = screen.getAllByRole("radio");
expect(radioButtons).toHaveLength(2);
expect(radioButtons[0]).toBeChecked();
expect(radioButtons[1]).not.toBeChecked();
});
test("prevents default action when clicking image expand link", async () => {
render(<PictureSelectionQuestion {...mockProps} />);
const links = screen.getAllByTitle("Open in new tab");
const mockStopPropagation = vi.fn();
// Simulate clicking the link but prevent the event from propagating
fireEvent.click(links[0], { stopPropagation: mockStopPropagation });
// The onChange should not be called because stopPropagation prevents it
expect(mockProps.onChange).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,258 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TResponseTtc } from "@formbricks/types/responses";
import { TSurveyQuestionTypeEnum, type TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
import { RankingQuestion } from "./ranking-question";
// Mock components used in the RankingQuestion component
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({ onClick, backButtonLabel }: { onClick: () => void; backButtonLabel: string }) => (
<button data-testid="back-button" onClick={onClick}>
{backButtonLabel}
</button>
),
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel }: { buttonLabel: string }) => (
<button data-testid="submit-button" type="submit">
{buttonLabel}
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline }: { headline: string }) => <h1 data-testid="headline">{headline}</h1>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: { subheader: string }) => <p data-testid="subheader">{subheader}</p>,
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media"></div>,
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="scrollable-container">{children}</div>
),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: (value: any, _: string) => (typeof value === "string" ? value : value.default),
}));
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: () => ({}),
}));
vi.mock("@/lib/utils", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
getShuffledChoicesIds: (choices: any[], _?: string) => choices.map((c) => c.id),
}));
describe("RankingQuestion", () => {
afterEach(() => {
cleanup();
});
const mockQuestion: TSurveyRankingQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Rank these items" },
subheader: { default: "Please rank all items" },
required: true,
choices: [
{ id: "c1", label: { default: "Item 1" } },
{ id: "c2", label: { default: "Item 2" } },
{ id: "c3", label: { default: "Item 3" } },
],
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
};
const defaultProps = {
question: mockQuestion,
value: [] as string[],
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {} as TResponseTtc,
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
test("renders correctly with all elements", () => {
render(<RankingQuestion {...defaultProps} />);
expect(screen.getByTestId("headline")).toBeInTheDocument();
expect(screen.getByTestId("subheader")).toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("Item 3")).toBeInTheDocument();
});
test("renders media when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(<RankingQuestion {...defaultProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("doesn't show back button when isFirstQuestion is true", () => {
render(<RankingQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("doesn't show back button when isBackButtonHidden is true", () => {
render(<RankingQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("clicking on item adds it to the sorted list", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} />);
const item1 = screen.getByText("Item 1").closest("div");
await user.click(item1!);
const itemElements = screen
.getAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(itemElements.length).toBeGreaterThan(0);
});
test("clicking on a sorted item removes it from the sorted list", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} value={["c1"]} />);
// First verify the item is in the sorted list
const sortedItems = screen
.getAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(sortedItems.length).toBeGreaterThan(0);
// Click the item to unselect it
const item1 = screen.getByText("Item 1").closest("div");
await user.click(item1!);
// The move buttons should be gone
const moveButtons = screen
.queryAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(moveButtons.length).toBe(0);
});
test("moving an item up in the list", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} value={["c1", "c2"]} />);
const upButtons = screen.getAllByRole("button").filter((btn) => btn.innerHTML.includes("chevron-up"));
// The first item's up button should be disabled
expect(upButtons[0]).toBeDisabled();
// The second item's up button should work
expect(upButtons[1]).not.toBeDisabled();
await user.click(upButtons[1]);
});
test("moving an item down in the list", async () => {
// For this test, we'll just verify the component renders correctly with ranked items
render(<RankingQuestion {...defaultProps} value={["c1", "c2"]} />);
// Verify both items are rendered
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
// Verify there are some move buttons present
const buttons = screen.getAllByRole("button");
const moveButtons = buttons.filter(
(btn) =>
btn.innerHTML && (btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"))
);
// Just make sure we have some move buttons rendered
expect(moveButtons.length).toBeGreaterThan(0);
});
test("submits form with complete ranking", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} value={["c1", "c2", "c3"]} />);
const submitButton = screen.getByTestId("submit-button");
await user.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalled();
expect(screen.queryByText("Please rank all items before submitting.")).not.toBeInTheDocument();
});
test("clicking back button calls onBack", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} />);
const backButton = screen.getByTestId("back-button");
await user.click(backButton);
expect(defaultProps.onChange).toHaveBeenCalled();
expect(defaultProps.onBack).toHaveBeenCalled();
});
test("allows incomplete ranking if not required", async () => {
const user = userEvent.setup();
const nonRequiredQuestion = {
...mockQuestion,
required: false,
};
render(<RankingQuestion {...defaultProps} question={nonRequiredQuestion} value={[]} />);
const submitButton = screen.getByTestId("submit-button");
await user.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalled();
});
test("handles keyboard navigation", () => {
render(<RankingQuestion {...defaultProps} />);
const item = screen.getByText("Item 1").closest("div");
fireEvent.keyDown(item!, { key: " " }); // Space key
const moveButtons = screen
.getAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(moveButtons.length).toBeGreaterThan(0);
});
test("applies shuffle option correctly", () => {
const shuffledQuestion = {
...mockQuestion,
shuffleOption: "all",
} as TSurveyRankingQuestion;
render(<RankingQuestion {...defaultProps} question={shuffledQuestion} />);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("Item 3")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,241 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponseTtc } from "@formbricks/types/responses";
import { TSurveyQuestionTypeEnum, TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import { RatingQuestion } from "./rating-question";
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({ onClick, backButtonLabel, tabIndex }: any) => (
<button data-testid="back-button" onClick={onClick} tabIndex={tabIndex}>
{backButtonLabel}
</button>
),
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel, tabIndex }: any) => (
<button data-testid="submit-button" tabIndex={tabIndex}>
{buttonLabel}
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline, questionId, required }: any) => (
<h2 data-testid="headline" data-required={required} data-question-id={questionId}>
{headline}
</h2>
),
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader, questionId }: any) => (
<p data-testid="subheader" data-question-id={questionId}>
{subheader}
</p>
),
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: ({ imgUrl, videoUrl }: any) => (
<div data-testid="question-media" data-img-url={imgUrl} data-video-url={videoUrl}></div>
),
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: any) => <div data-testid="scrollable-container">{children}</div>,
}));
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: (value: any) => (typeof value === "string" ? value : value.default),
}));
vi.mock("@/lib/ttc", () => ({
getUpdatedTtc: vi.fn().mockReturnValue({}),
useTtc: vi.fn(),
}));
vi.mock("preact/hooks", async () => {
const actual = await vi.importActual<typeof import("preact/hooks")>("preact/hooks");
return {
...actual,
useState: vi.fn().mockImplementation(actual.useState),
useEffect: vi.fn().mockImplementation(actual.useEffect),
};
});
describe("RatingQuestion", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockQuestion: TSurveyRatingQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "How would you rate our service?" },
subheader: { default: "Please give us your honest feedback" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Very poor" },
upperLabel: { default: "Excellent" },
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
isColorCodingEnabled: true,
};
const mockProps = {
question: mockQuestion,
value: undefined,
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {} as TResponseTtc,
setTtc: vi.fn(),
autoFocusEnabled: true,
currentQuestionId: "q1",
isBackButtonHidden: false,
};
test("renders the question correctly", () => {
render(<RatingQuestion {...mockProps} />);
expect(screen.getByTestId("headline")).toHaveTextContent("How would you rate our service?");
expect(screen.getByTestId("subheader")).toHaveTextContent("Please give us your honest feedback");
expect(screen.queryByTestId("question-media")).not.toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
});
test("renders media when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "image.jpg",
};
render(<RatingQuestion {...mockProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("handles number scale correctly", async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<RatingQuestion {...mockProps} />);
const ratingOption = screen.getByText("3");
await user.click(ratingOption);
expect(mockProps.onChange).toHaveBeenCalledWith({ q1: 3 });
// Fast-forward timers to handle the setTimeout in the component
vi.advanceTimersByTime(250);
expect(mockProps.onSubmit).toHaveBeenCalled();
vi.useRealTimers();
});
test("handles star scale correctly", async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const starQuestion = {
...mockQuestion,
scale: "star" as const,
};
render(<RatingQuestion {...mockProps} question={starQuestion} />);
const stars = screen.getAllByRole("radio");
await user.click(stars[2]); // Click the 3rd star
expect(mockProps.onChange).toHaveBeenCalledWith({ q1: 3 });
vi.advanceTimersByTime(250);
expect(mockProps.onSubmit).toHaveBeenCalled();
vi.useRealTimers();
});
test("handles smiley scale correctly", async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const smileyQuestion = {
...mockQuestion,
scale: "smiley" as const,
};
render(<RatingQuestion {...mockProps} question={smileyQuestion} />);
const smileys = screen.getAllByRole("radio");
await user.click(smileys[2]); // Click the 3rd smiley
expect(mockProps.onChange).toHaveBeenCalledWith({ q1: 3 });
vi.advanceTimersByTime(250);
expect(mockProps.onSubmit).toHaveBeenCalled();
vi.useRealTimers();
});
test("hides back button when isFirstQuestion is true", () => {
render(<RatingQuestion {...mockProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("hides back button when isBackButtonHidden is true", () => {
render(<RatingQuestion {...mockProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("handles form submission", async () => {
const user = userEvent.setup();
const { container } = render(
<RatingQuestion {...mockProps} value={4} question={{ ...mockQuestion, required: false }} />
);
const form = container.querySelector("form");
expect(form).toBeInTheDocument();
await user.click(screen.getByTestId("submit-button"));
expect(mockProps.onSubmit).toHaveBeenCalled();
});
test("handles keyboard navigation via spacebar", async () => {
const user = userEvent.setup();
render(<RatingQuestion {...mockProps} />);
await user.tab(); // Focus on first rating option
await user.keyboard(" "); // Press spacebar
expect(mockProps.onChange).toHaveBeenCalled();
});
test("triggers onBack when back button is clicked", async () => {
const user = userEvent.setup();
render(<RatingQuestion {...mockProps} />);
const backButton = screen.getByTestId("back-button");
await user.click(backButton);
expect(mockProps.onBack).toHaveBeenCalled();
});
test("supports color coding when enabled", () => {
render(<RatingQuestion {...mockProps} />);
// Check if color coding elements are present
const colorElements = document.querySelectorAll('[class*="fb-h-[6px]"]');
expect(colorElements.length).toBeGreaterThan(0);
});
});

View File

@@ -23,12 +23,9 @@ const config = ({ mode }) => {
reporter: ["text", "html", "lcov"],
reportsDirectory: "./coverage",
include: [
"src/lib/api-client.ts",
"src/lib/response-queue.ts",
"src/lib/logic.ts",
"src/components/buttons/*.tsx"
"src/lib/**/*.{ts,tsx}",
"src/components/**/*.{ts,tsx}"
],
exclude: ["dist/**", "node_modules/**"],
},
},
define: {

View File

@@ -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
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
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/**