From c5d387a7e579bdd6c8bffe6597ecb0978d0136af Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 14 May 2025 15:29:22 +0530 Subject: [PATCH] fix: multi choice issues (#5802) --- .../multiple-choice-multi-question.test.tsx | 201 ++++++++++++++---- .../multiple-choice-multi-question.tsx | 30 ++- 2 files changed, 183 insertions(+), 48 deletions(-) diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.test.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.test.tsx index ff4a1134f1..64930cdae2 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.test.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.test.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; +import { ComponentChildren } from "preact"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { TSurveyMultipleChoiceQuestion } from "@formbricks/types/surveys/types"; import { MultipleChoiceMultiQuestion } from "./multiple-choice-multi-question"; @@ -31,11 +32,13 @@ vi.mock("@/components/general/subheader", () => ({ })); vi.mock("@/components/general/question-media", () => ({ - QuestionMedia: () =>
, + QuestionMedia: ({ imgUrl, videoUrl }: { imgUrl?: string; videoUrl?: string }) => ( +
+ ), })); vi.mock("@/components/wrappers/scrollable-container", () => ({ - ScrollableContainer: ({ children }: { children: React.ReactNode }) => ( + ScrollableContainer: ({ children }: { children: ComponentChildren }) => (
{children}
), })); @@ -52,6 +55,18 @@ vi.mock("@/lib/i18n", () => ({ }, })); +// Mock the utils for shuffling +vi.mock("@/lib/utils", () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(" "), + getShuffledChoicesIds: vi.fn((choices: Array<{ id: string }>, option: string) => { + if (option === "all") { + // Return in reverse to simulate shuffling + return choices.map((choice: { id: string }) => choice.id).reverse(); + } + return choices.map((choice: { id: string }) => choice.id); + }), +})); + describe("MultipleChoiceMultiQuestion", () => { afterEach(() => { cleanup(); @@ -149,46 +164,6 @@ describe("MultipleChoiceMultiQuestion", () => { expect(onSubmit).toHaveBeenCalledWith({ q1: ["Option 1"] }, { questionId: "ttc-value" }); }); - test("filters out invalid values during submission", async () => { - const onSubmit = vi.fn(); - const { container } = render( - - ); - - // Submit the form - const form = container.querySelector("form"); - fireEvent.submit(form!); - - // Check that onSubmit was called with only valid values - expect(onSubmit).toHaveBeenCalledWith({ q1: ["Option 1"] }, { questionId: "ttc-value" }); - }); - - test("calls onChange with updated values during submission", async () => { - const onChange = vi.fn(); - const onSubmit = vi.fn(); - const { container } = render( - - ); - - // Submit the form - const form = container.querySelector("form"); - fireEvent.submit(form!); - - // Check that onChange was called with filtered values - expect(onChange).toHaveBeenCalledWith({ q1: ["Option 1"] }); - expect(onSubmit).toHaveBeenCalledWith({ q1: ["Option 1"] }, { questionId: "ttc-value" }); - }); - test("calls onBack when back button is clicked", async () => { const onBack = vi.fn(); render(); @@ -207,25 +182,159 @@ describe("MultipleChoiceMultiQuestion", () => { expect(screen.queryByTestId("back-button")).not.toBeInTheDocument(); }); - test("renders media when available", () => { - const questionWithMedia = { + test("renders image media when available", () => { + const questionWithImage = { ...defaultProps.question, imageUrl: "https://example.com/image.jpg", }; - render(); + render(); expect(screen.getByTestId("question-media")).toBeInTheDocument(); + expect(screen.getByTestId("question-media")).toHaveAttribute( + "data-img-url", + "https://example.com/image.jpg" + ); }); - test("handles shuffled choices correctly", () => { + test("renders video media when available", () => { + const questionWithVideo = { + ...defaultProps.question, + videoUrl: "https://example.com/video.mp4", + }; + + render(); + expect(screen.getByTestId("question-media")).toBeInTheDocument(); + expect(screen.getByTestId("question-media")).toHaveAttribute( + "data-video-url", + "https://example.com/video.mp4" + ); + }); + + test("handles shuffled choices correctly with 'all' option", () => { const shuffledQuestion = { ...defaultProps.question, shuffleOption: "all", } as TSurveyMultipleChoiceQuestion; render(); + + // All options should still be rendered regardless of shuffle 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 keyboard accessibility with spacebar", async () => { + render(); + + // Find the label for the first option + const option1Label = screen.getByText("Option 1").closest("label"); + expect(option1Label).toBeInTheDocument(); + + // Simulate pressing spacebar on the label + fireEvent.keyDown(option1Label!, { key: " " }); + + // Check if onChange was called with the correct value + expect(defaultProps.onChange).toHaveBeenCalledWith({ q1: ["Option 1"] }); + }); + + test("handles deselecting 'Other' option", async () => { + const onChange = vi.fn(); + // Initial render with 'Other' already selected and a custom value + const { rerender } = render( + + ); + + // Verify 'Other' is checked using id + const otherCheckbox = screen.getByRole("checkbox", { name: "Other" }); + expect(otherCheckbox).toBeInTheDocument(); + expect(otherCheckbox).toBeChecked(); + + // Also verify the input has the custom value + expect(screen.getByDisplayValue("Custom response")).toBeInTheDocument(); + + // Click to deselect the 'Other' option + await userEvent.click(otherCheckbox); + + // Check if onChange was called with empty array + expect(onChange).toHaveBeenCalledWith({ q1: [] }); + + // Rerender to update the component with new value + rerender(); + + // Verify the input field is not displayed anymore + expect(screen.queryByPlaceholderText("Please specify")).not.toBeInTheDocument(); + }); + + test("initializes with 'Other' selected when value doesn't match any choice", () => { + render(); + + // Verify 'Other' is checked + const otherCheckbox = screen.getByRole("checkbox", { name: "Other" }); + expect(otherCheckbox).toBeChecked(); + + // Verify the input has the custom value + expect(screen.getByDisplayValue("Custom answer")).toBeInTheDocument(); + }); + + test("combines regular choices and 'Other' value on submission", async () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + // Verify both Option 1 and Other are checked + const option1Checkbox = screen.getByRole("checkbox", { name: "Option 1" }); + const otherCheckbox = screen.getByRole("checkbox", { name: "Other" }); + expect(option1Checkbox).toBeChecked(); + expect(otherCheckbox).toBeChecked(); + + // Get the form and submit it + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + fireEvent.submit(form!); + + // Check if onSubmit was called with both values + expect(onSubmit).toHaveBeenCalledWith({ q1: ["Option 1", "Custom answer"] }, { questionId: "ttc-value" }); + }); + + test("handles required validation correctly", async () => { + const onSubmit = vi.fn(); + // Create a non-required question + const nonRequiredQuestion = { + ...defaultProps.question, + required: false, + }; + + const { container, rerender } = render( + + ); + + // Get the form and submit it with empty selection + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + fireEvent.submit(form!); + + // Check if onSubmit was called even with empty value + expect(onSubmit).toHaveBeenCalledWith({ q1: [] }, { questionId: "ttc-value" }); + + // Now test with required=true + vi.clearAllMocks(); + rerender(); + + // Check if at least one checkbox has the required attribute + const checkboxes = screen.getAllByRole("checkbox"); + const hasRequiredCheckbox = checkboxes.some((checkbox) => checkbox.hasAttribute("required")); + expect(hasRequiredCheckbox).toBe(true); }); }); diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx index f9334de293..6ab4c55d45 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx @@ -61,8 +61,17 @@ export function MultipleChoiceMultiQuestion({ .map((item) => getLocalizedValue(item.label, languageCode)), [question, languageCode] ); - const [otherSelected, setOtherSelected] = useState(false); - const [otherValue, setOtherValue] = useState(""); + const [otherSelected, setOtherSelected] = useState( + Boolean(value) && + (Array.isArray(value) ? value : [value]).some((item) => { + return !getChoicesWithoutOtherLabels().includes(item); + }) + ); + const [otherValue, setOtherValue] = useState( + (Array.isArray(value) && + value.filter((v) => !question.choices.find((c) => c.label[languageCode] === v))[0]) || + "" + ); const questionChoices = useMemo(() => { if (!question.choices) { @@ -239,6 +248,14 @@ export function MultipleChoiceMultiQuestion({ className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0" aria-labelledby={`${otherOption.id}-label`} onChange={() => { + if (otherSelected) { + setOtherValue(""); + onChange({ + [question.id]: value.filter((item) => { + return getChoicesWithoutOtherLabels().includes(item); + }), + }); + } setOtherSelected(!otherSelected); }} checked={otherSelected} @@ -266,6 +283,15 @@ export function MultipleChoiceMultiQuestion({ } required={question.required} aria-labelledby={`${otherOption.id}-label`} + onBlur={() => { + const newValue = value.filter((item) => { + return getChoicesWithoutOtherLabels().includes(item); + }); + if (otherValue && otherSelected) { + newValue.push(otherValue); + onChange({ [question.id]: newValue }); + } + }} /> ) : null}