fix: allow deselecting optional single-select question responses (#6643)

Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Johannes
2025-10-06 02:32:24 -07:00
committed by GitHub
parent 4a3c2fccba
commit d9ea00d86e
11 changed files with 128 additions and 27 deletions

View File

@@ -279,7 +279,6 @@
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht berechtigt",
"not_connected": "Nicht verbunden",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
"add_logic": "Logik hinzufügen",
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
"add_option": "Option hinzufügen",
"add_other": "Anderes hinzufügen",
"add_photo_or_video": "Foto oder Video hinzufügen",

View File

@@ -279,7 +279,6 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
"not_connected": "Not Connected",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Add an outer border to your survey card.",
"add_logic": "Add logic",
"add_none_of_the_above": "Add \"None of the Above\"",
"add_option": "Add option",
"add_other": "Add \"Other\"",
"add_photo_or_video": "Add photo or video",

View File

@@ -279,7 +279,6 @@
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
"not_connected": "Non connecté",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
"add_logic": "Ajouter de la logique",
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
"add_option": "Ajouter une option",
"add_other": "Ajouter \"Autre",
"add_photo_or_video": "Ajouter une photo ou une vidéo",

View File

@@ -279,7 +279,6 @@
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
"not_connected": "未接続",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "ハイライトボーダーを追加",
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
"add_logic": "ロジックを追加",
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
"add_option": "オプションを追加",
"add_other": "「その他」を追加",
"add_photo_or_video": "写真または動画を追加",

View File

@@ -279,7 +279,6 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Você não está autenticado para realizar essa ação.",
"not_authorized": "Não autorizado",
"not_connected": "Desconectado",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
"add_option": "Adicionar opção",
"add_other": "Adicionar \"Outro",
"add_photo_or_video": "Adicionar foto ou video",

View File

@@ -279,7 +279,6 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Não está autenticado para realizar esta ação.",
"not_authorized": "Não autorizado",
"not_connected": "Não Conectado",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
"add_option": "Adicionar opção",
"add_other": "Adicionar \"Outro\"",
"add_photo_or_video": "Adicionar foto ou vídeo",

View File

@@ -279,7 +279,6 @@
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
"none_of_the_above": "Niciuna dintre cele de mai sus",
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
"not_authorized": "Neautorizat",
"not_connected": "Neconectat",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "Adaugă bordură evidențiată",
"add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.",
"add_logic": "Adaugă logică",
"add_none_of_the_above": "Adăugați \"Niciuna dintre cele de mai sus\"",
"add_option": "Adăugați opțiune",
"add_other": "Adăugați \"Altele\"",
"add_photo_or_video": "Adaugă fotografie sau video",

View File

@@ -279,7 +279,6 @@
"no_result_found": "没有 结果",
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
"not_connected": "未连接",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "添加 高亮 边框",
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
"add_logic": "添加逻辑",
"add_none_of_the_above": "添加 “以上 都 不 是”",
"add_option": "添加 选项",
"add_other": "添加 \"其他\"",
"add_photo_or_video": "添加 照片 或 视频",

View File

@@ -279,7 +279,6 @@
"no_result_found": "找不到結果",
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
"none_of_the_above": "以上皆非",
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
"not_connected": "未連線",
@@ -1210,7 +1209,6 @@
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
"add_logic": "新增邏輯",
"add_none_of_the_above": "新增 \"以上皆非\"",
"add_option": "新增選項",
"add_other": "新增「其他」",
"add_photo_or_video": "新增照片或影片",

View File

@@ -272,4 +272,117 @@ describe("MultipleChoiceSingleQuestion", () => {
const backButton = screen.getByTestId("back-button");
expect(backButton).toHaveAttribute("tabIndex", "0");
});
test("allows deselecting a choice when question is not required", async () => {
const user = userEvent.setup();
const optionalQuestion = { ...mockQuestion, required: false };
render(<MultipleChoiceSingleQuestion {...defaultProps} question={optionalQuestion} value="Choice 1" />);
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: undefined });
});
test("does not deselect a choice when question is required", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="Choice 1" />);
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).not.toHaveBeenCalledWith({ q1: undefined });
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("allows deselecting 'Other' option when question is not required", async () => {
const user = userEvent.setup();
const optionalQuestion = { ...mockQuestion, required: false };
render(<MultipleChoiceSingleQuestion {...defaultProps} question={optionalQuestion} value="Some text" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: undefined });
});
test("does not deselect 'Other' option when question is required", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="Some text" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(mockOnChange).not.toHaveBeenCalledWith({ q1: undefined });
});
test("clears otherSelected when selecting a regular choice after 'Other' was selected", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
mockOnChange.mockClear();
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("handles spacebar key press on 'Other' option label when not selected", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const otherLabel = screen.getByLabelText("Other").closest("label");
if (otherLabel) {
await user.type(otherLabel, " ");
}
expect(mockOnChange).toHaveBeenCalledWith({ q1: "" });
});
test("does not trigger click when spacebar is pressed on 'Other' option label and otherSelected is true", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
mockOnChange.mockClear();
const otherLabel = screen.getByRole("radio", { name: "Other" }).closest("label");
if (otherLabel) {
await user.type(otherLabel, " ");
}
expect(mockOnChange).not.toHaveBeenCalled();
});
test("displays custom other option placeholder when provided", async () => {
const user = userEvent.setup();
const questionWithCustomPlaceholder = {
...mockQuestion,
otherOptionPlaceholder: { default: "Custom placeholder text" },
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithCustomPlaceholder} />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Custom placeholder text")).toBeInTheDocument();
});
});

View File

@@ -1,3 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
@@ -7,9 +10,6 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface MultipleChoiceSingleProps {
question: TSurveyMultipleChoiceQuestion;
@@ -164,9 +164,14 @@ export function MultipleChoiceSingleQuestion({
dir={dir}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => {
setOtherSelected(false);
onChange({ [question.id]: getLocalizedValue(choice.label, languageCode) });
onClick={() => {
const choiceValue = getLocalizedValue(choice.label, languageCode);
if (!question.required && value === choiceValue) {
onChange({ [question.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [question.id]: choiceValue });
}
}}
checked={value === getLocalizedValue(choice.label, languageCode)}
required={question.required ? idx === 0 : undefined}
@@ -209,10 +214,11 @@ export function MultipleChoiceSingleQuestion({
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onClick={() => {
if (otherSelected) {
if (otherSelected && !question.required) {
onChange({ [question.id]: undefined });
} else {
setOtherSelected(!otherSelected);
setOtherSelected(false);
} else if (!otherSelected) {
setOtherSelected(true);
onChange({ [question.id]: "" });
}
}}