feat: Add "None of the above" option for Multi-Select and Single-Select questions (#6646)

This commit is contained in:
Johannes
2025-10-10 07:50:45 -07:00
committed by GitHub
parent 5468510f5a
commit 18f4cd977d
21 changed files with 1776 additions and 145 deletions

View File

@@ -1,12 +1,4 @@
import "server-only";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
@@ -41,6 +33,14 @@ import {
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
interface TSurveySummaryResponse {
@@ -345,20 +345,23 @@ export const getQuestionSummary = async (
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
// check last choice is others or not
const lastChoice = question.choices[question.choices.length - 1];
const isOthersEnabled = lastChoice.id === "other";
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
if (isOthersEnabled) {
questionChoices.pop();
}
const otherOption = question.choices.find((choice) => choice.id === "other");
const noneOption = question.choices.find((choice) => choice.id === "none");
const questionChoices = question.choices
.filter((choice) => choice.id !== "other" && choice.id !== "none")
.map((choice) => getLocalizedValue(choice.label, "default"));
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
acc[choice] = 0;
return acc;
}, {});
// Track "none" count separately
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
let noneCount = 0;
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
let totalSelectionCount = 0;
let totalResponseCount = 0;
@@ -378,7 +381,9 @@ export const getQuestionSummary = async (
totalSelectionCount++;
if (questionChoices.includes(value)) {
choiceCountMap[value]++;
} else if (isOthersEnabled) {
} else if (noneLabel && value === noneLabel) {
noneCount++;
} else if (otherOption) {
otherValues.push({
value,
contact: response.contact,
@@ -396,7 +401,9 @@ export const getQuestionSummary = async (
totalSelectionCount++;
if (questionChoices.includes(answer)) {
choiceCountMap[answer]++;
} else if (isOthersEnabled) {
} else if (noneLabel && answer === noneLabel) {
noneCount++;
} else if (otherOption) {
otherValues.push({
value: answer,
contact: response.contact,
@@ -421,9 +428,9 @@ export const getQuestionSummary = async (
});
});
if (isOthersEnabled) {
if (otherOption) {
values.push({
value: getLocalizedValue(lastChoice.label, "default") || "Other",
value: getLocalizedValue(otherOption.label, "default") || "Other",
count: otherValues.length,
percentage:
totalResponseCount > 0
@@ -432,6 +439,17 @@ export const getQuestionSummary = async (
others: otherValues.slice(0, VALUES_LIMIT),
});
}
// Add "none" option at the end if it exists
if (noneOption && noneLabel) {
values.push({
value: noneLabel,
count: noneCount,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((noneCount / totalResponseCount) * 100) : 0,
});
}
summary.push({
type: question.type,
question,

View File

@@ -279,6 +279,7 @@
"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",
@@ -1203,12 +1204,12 @@
"add_description": "Beschreibung hinzufügen",
"add_ending": "Abschluss hinzufügen",
"add_ending_below": "Abschluss unten hinzufügen",
"add_fallback": "Hinzufügen",
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"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",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
"everyone": "Jeder",
"fallback_for": "Ersatz für",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",

View File

@@ -279,6 +279,7 @@
"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",
@@ -1203,12 +1204,12 @@
"add_description": "Add description",
"add_ending": "Add ending",
"add_ending_below": "Add ending below",
"add_fallback": "Add",
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
"add_hidden_field_id": "Add hidden field ID",
"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",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
"everyone": "Everyone",
"fallback_for": "Fallback for ",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",

View File

@@ -279,6 +279,7 @@
"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é",
@@ -1203,12 +1204,12 @@
"add_description": "Ajouter une description",
"add_ending": "Ajouter une fin",
"add_ending_below": "Ajouter une fin ci-dessous",
"add_fallback": "Ajouter",
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
"add_hidden_field_id": "Ajouter un champ caché ID",
"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",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
"everyone": "Tout le monde",
"fallback_for": "Solution de repli pour ",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",

View File

@@ -279,6 +279,7 @@
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
"not_connected": "未接続",
@@ -1203,12 +1204,12 @@
"add_description": "説明を追加",
"add_ending": "終了を追加",
"add_ending_below": "以下に終了を追加",
"add_fallback": "追加",
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
"add_hidden_field_id": "非表示フィールドIDを追加",
"add_highlight_border": "ハイライトボーダーを追加",
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
"add_logic": "ロジックを追加",
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
"add_option": "オプションを追加",
"add_other": "「その他」を追加",
"add_photo_or_video": "写真または動画を追加",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
"everyone": "全員",
"fallback_for": "のフォールバック",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",

View File

@@ -279,6 +279,7 @@
"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",
@@ -1203,12 +1204,12 @@
"add_description": "Adicionar Descrição",
"add_ending": "Adicionar final",
"add_ending_below": "Adicione o final abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"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",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todo mundo",
"fallback_for": "Alternativa para",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",

View File

@@ -279,6 +279,7 @@
"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",
@@ -1203,12 +1204,12 @@
"add_description": "Adicionar descrição",
"add_ending": "Adicionar encerramento",
"add_ending_below": "Adicionar encerramento abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"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",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todos",
"fallback_for": "Alternativa para ",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",

View File

@@ -279,6 +279,7 @@
"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",
@@ -1203,12 +1204,12 @@
"add_description": "Adăugați descriere",
"add_ending": "Adaugă finalizare",
"add_ending_below": "Adaugă finalizare mai jos",
"add_fallback": "Adaugă",
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
"add_hidden_field_id": "Adăugați ID câmp ascuns",
"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",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
"everyone": "Toată lumea",
"fallback_for": "Varianta de rezervă pentru",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",

View File

@@ -279,6 +279,7 @@
"no_result_found": "没有 结果",
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
"not_connected": "未连接",
@@ -1203,12 +1204,12 @@
"add_description": "添加 描述",
"add_ending": "添加结尾",
"add_ending_below": "在下方 添加 结尾",
"add_fallback": "添加",
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
"add_hidden_field_id": "添加 隐藏 字段 ID",
"add_highlight_border": "添加 高亮 边框",
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
"add_logic": "添加逻辑",
"add_none_of_the_above": "添加 “以上 都 不 是”",
"add_option": "添加 选项",
"add_other": "添加 \"其他\"",
"add_photo_or_video": "添加 照片 或 视频",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
"everyone": "所有 人",
"fallback_for": "后备 用于",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",

View File

@@ -279,6 +279,7 @@
"no_result_found": "找不到結果",
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
"none_of_the_above": "以上皆非",
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
"not_connected": "未連線",
@@ -1203,12 +1204,12 @@
"add_description": "新增描述",
"add_ending": "新增結尾",
"add_ending_below": "在下方新增結尾",
"add_fallback": "新增",
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
"add_logic": "新增邏輯",
"add_none_of_the_above": "新增 \"以上皆非\"",
"add_option": "新增選項",
"add_other": "新增「其他」",
"add_photo_or_video": "新增照片或影片",
@@ -1343,7 +1344,6 @@
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
"everyone": "所有人",
"fallback_for": "備用 用於 ",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",

View File

@@ -1,3 +1,9 @@
import type { Account, NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TUser } from "@formbricks/types/user";
import {
EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY,
@@ -21,12 +27,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import type { Account, NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TUser } from "@formbricks/types/user";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
@@ -70,14 +70,20 @@ export const authOptions: NextAuthOptions = {
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
if (credentials.password && credentials.password.length > 128) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
logAuthAttempt(
"password_too_long",
"credentials",
"password_validation",
UNKNOWN_DATA,
credentials?.email
);
}
throw new Error("Invalid credentials");
}
// Use a control hash when user doesn't exist to maintain constant timing.
// Use a control hash when user doesn't exist to maintain constant timing.
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
let user;
try {
// Perform database lookup

View File

@@ -6,7 +6,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useRef, useState } from "react";
import { type JSX, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import {
TI18nString,
@@ -64,7 +64,7 @@ export const MultipleChoiceQuestionForm = ({
all: {
id: "all",
label: t("environments.surveys.edit.randomize_all"),
show: question.choices.filter((c) => c.id === "other").length === 0,
show: question.choices.every((c) => c.id !== "other" && c.id !== "none"),
},
exceptLast: {
id: "exceptLast",
@@ -87,48 +87,62 @@ export const MultipleChoiceQuestionForm = ({
});
};
const regularChoices = useMemo(
() => question.choices?.filter((c) => c.id !== "other" && c.id !== "none"),
[question.choices]
);
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceQuestion["choices"]) => {
const otherChoice = choices.find((c) => c.id === "other");
const noneChoice = choices.find((c) => c.id === "none");
// [regularChoices, otherChoice, noneChoice]
return [...regularChoices, ...(otherChoice ? [otherChoice] : []), ...(noneChoice ? [noneChoice] : [])];
};
const addChoice = (choiceIdx?: number) => {
setIsNew(false); // This question is no longer new.
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
newChoices = newChoices.filter((choice) => choice.id !== "other");
}
setIsNew(false);
const newChoice = {
id: createId(),
label: createI18nString("", surveyLanguageCodes),
};
if (choiceIdx !== undefined) {
newChoices.splice(choiceIdx + 1, 0, newChoice);
regularChoices.splice(choiceIdx + 1, 0, newChoice);
} else {
newChoices.push(newChoice);
}
if (otherChoice) {
newChoices.push(otherChoice);
regularChoices.push(newChoice);
}
const newChoices = ensureSpecialChoicesOrder([
...regularChoices,
...question.choices.filter((c) => c.id === "other" || c.id === "none"),
]);
updateQuestion(questionIdx, { choices: newChoices });
};
const addOther = () => {
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({
id: "other",
label: createI18nString("Other", surveyLanguageCodes),
});
updateQuestion(questionIdx, {
choices: newChoices,
...(question.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id as TShuffleOption,
}),
});
}
const addSpecialChoice = (choiceId: "other" | "none", labelText: string) => {
if (question.choices.some((c) => c.id === choiceId)) return;
const newChoice = {
id: choiceId,
label: createI18nString(labelText, surveyLanguageCodes),
};
const newChoices = ensureSpecialChoicesOrder([...question.choices, newChoice]);
updateQuestion(questionIdx, {
choices: newChoices,
...(question.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id as TShuffleOption,
}),
});
};
const deleteChoice = (choiceIdx: number) => {
const choiceToDelete = question.choices[choiceIdx].id;
if (choiceToDelete !== "other") {
if (choiceToDelete !== "other" && choiceToDelete !== "none") {
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
if (questionIdx !== -1) {
toast.error(
@@ -164,6 +178,21 @@ export const MultipleChoiceQuestionForm = ({
}
}, [isNew]);
const specialChoices = [
{
id: "other",
label: t("common.other"),
addChoice: () => addSpecialChoice("other", t("common.other")),
addButtonText: t("environments.surveys.edit.add_other"),
},
{
id: "none",
label: t("common.none_of_the_above"),
addChoice: () => addSpecialChoice("none", t("common.none_of_the_above")),
addButtonText: t("environments.surveys.edit.add_none_of_the_above"),
},
];
// Auto animate
const [parent] = useAutoAnimate();
return (
@@ -227,7 +256,12 @@ export const MultipleChoiceQuestionForm = ({
onDragEnd={(event) => {
const { active, over } = event;
if (active.id === "other" || over?.id === "other") {
if (
active.id === "other" ||
over?.id === "other" ||
active.id === "none" ||
over?.id === "none"
) {
return;
}
@@ -272,11 +306,21 @@ export const MultipleChoiceQuestionForm = ({
</SortableContext>
</DndContext>
<div className="mt-2 flex items-center justify-between space-x-2">
{question.choices.filter((c) => c.id === "other").length === 0 && (
<Button size="sm" variant="secondary" type="button" onClick={() => addOther()}>
{t("environments.surveys.edit.add_other")}
</Button>
)}
<div className="flex gap-2">
{specialChoices.map((specialChoice) => {
if (question.choices.some((c) => c.id === specialChoice.id)) return null;
return (
<Button
size="sm"
key={specialChoice.id}
variant="secondary"
type="button"
onClick={() => specialChoice.addChoice()}>
{specialChoice.addButtonText}
</Button>
);
})}
</div>
<Button
size="sm"
variant="secondary"

View File

@@ -61,10 +61,10 @@ export const QuestionOptionChoice = ({
isStorageConfigured,
}: ChoiceProps) => {
const { t } = useTranslate();
const isDragDisabled = choice.id === "other";
const isSpecialChoice = choice.id === "other" || choice.id === "none";
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: choice.id,
disabled: isDragDisabled,
disabled: isSpecialChoice,
});
const style = {
@@ -83,10 +83,18 @@ export const QuestionOptionChoice = ({
setTimeout(() => focusChoiceInput(idx + 1), 0);
};
const getPlaceholder = () => {
if (choice.id === "other") return t("common.other");
if (choice.id === "none") return t("common.none_of_the_above");
return t("environments.surveys.edit.option_idx", { choiceIndex: choiceIdx + 1 });
};
const normalChoice = question.choices?.filter((c) => c.id !== "other" && c.id !== "none") || [];
return (
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
{/* drag handle */}
<div className={cn(choice.id === "other" && "invisible")} {...listeners} {...attributes}>
<div className={cn(isSpecialChoice && "invisible")} {...listeners} {...attributes}>
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
</div>
@@ -94,11 +102,7 @@ export const QuestionOptionChoice = ({
<QuestionFormInput
key={choice.id}
id={`choice-${choiceIdx}`}
placeholder={
choice.id === "other"
? t("common.other")
: t("environments.surveys.edit.option_idx", { choiceIndex: choiceIdx + 1 })
}
placeholder={getPlaceholder()}
label={""}
localSurvey={localSurvey}
questionIdx={questionIdx}
@@ -107,15 +111,15 @@ export const QuestionOptionChoice = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={
isInvalid && !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
isInvalid && !isLabelValidForAllLanguages(question.choices?.[choiceIdx]?.label, surveyLanguages)
}
className={`${choice.id === "other" ? "border border-dashed" : ""} mt-0`}
className={`${isSpecialChoice ? "border border-dashed" : ""} mt-0`}
locale={locale}
isStorageConfigured={isStorageConfigured}
onKeyDown={(e) => {
if (e.key === "Enter" && choice.id !== "other") {
e.preventDefault();
const lastChoiceIdx = question.choices.findLastIndex((c) => c.id !== "other");
const lastChoiceIdx = question.choices?.findLastIndex((c) => c.id !== "other") ?? -1;
if (choiceIdx === lastChoiceIdx) {
addChoiceAndFocus(choiceIdx);
@@ -126,7 +130,7 @@ export const QuestionOptionChoice = ({
if (e.key === "ArrowDown") {
e.preventDefault();
if (choiceIdx + 1 < question.choices.length) {
if (choiceIdx + 1 < (question.choices?.length ?? 0)) {
focusChoiceInput(choiceIdx + 1);
}
}
@@ -154,7 +158,7 @@ export const QuestionOptionChoice = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={
isInvalid && !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
isInvalid && !isLabelValidForAllLanguages(question.choices?.[choiceIdx]?.label, surveyLanguages)
}
className="border border-dashed"
locale={locale}
@@ -163,7 +167,7 @@ export const QuestionOptionChoice = ({
)}
</div>
<div className="flex gap-2">
{question.choices?.length > 2 && (
{(normalChoice.length > 2 || isSpecialChoice) && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.delete_choice")}>
<Button
variant="secondary"
@@ -177,7 +181,7 @@ export const QuestionOptionChoice = ({
</Button>
</TooltipRenderer>
)}
{choice.id !== "other" && (
{!isSpecialChoice && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_choice_below")}>
<Button
variant="secondary"

View File

@@ -446,15 +446,27 @@ export const getMatchValueProps = (
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
) {
const choices = selectedQuestion.choices.map((choice) => {
return {
label: getLocalizedValue(choice.label, "default"),
value: choice.id,
meta: {
type: "static",
},
};
});
const operatorsToFilterNone = [
"includesOneOf",
"includesAllOf",
"doesNotIncludeOneOf",
"doesNotIncludeAllOf",
];
const shouldFilterNone =
selectedQuestion.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
operatorsToFilterNone.includes(condition.operator);
const choices = selectedQuestion.choices
.filter((choice) => !shouldFilterNone || choice.id !== "none")
.map((choice) => {
return {
label: getLocalizedValue(choice.label, "default"),
value: choice.id,
meta: {
type: "static",
},
};
});
return {
show: true,

View File

@@ -338,4 +338,149 @@ describe("MultipleChoiceMultiQuestion", () => {
const hasRequiredCheckbox = checkboxes.some((checkbox) => checkbox.hasAttribute("required"));
expect(hasRequiredCheckbox).toBe(true);
});
test("renders and allows selecting 'None' option", async () => {
const onChange = vi.fn();
const questionWithNone = {
...defaultProps.question,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "c2", label: { en: "Option 2" } },
{ id: "none", label: { en: "None of the above" } },
],
} as TSurveyMultipleChoiceQuestion;
render(<MultipleChoiceMultiQuestion {...defaultProps} question={questionWithNone} onChange={onChange} />);
const noneCheckbox = screen.getByRole("checkbox", { name: "None of the above" });
expect(noneCheckbox).toBeInTheDocument();
await userEvent.click(noneCheckbox);
expect(onChange).toHaveBeenCalledWith({ q1: ["None of the above"] });
});
test("'None' option clears other selections when checked", async () => {
const onChange = vi.fn();
const questionWithNone = {
...defaultProps.question,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "c2", label: { en: "Option 2" } },
{ id: "none", label: { en: "None of the above" } },
],
} as TSurveyMultipleChoiceQuestion;
render(
<MultipleChoiceMultiQuestion
{...defaultProps}
question={questionWithNone}
value={["Option 1", "Option 2"]}
onChange={onChange}
/>
);
const noneCheckbox = screen.getByRole("checkbox", { name: "None of the above" });
await userEvent.click(noneCheckbox);
expect(onChange).toHaveBeenCalledWith({ q1: ["None of the above"] });
});
test("'None' option clears 'Other' selection when checked", async () => {
const onChange = vi.fn();
const questionWithBoth = {
...defaultProps.question,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "other", label: { en: "Other" } },
{ id: "none", label: { en: "None of the above" } },
],
} as TSurveyMultipleChoiceQuestion;
render(
<MultipleChoiceMultiQuestion
{...defaultProps}
question={questionWithBoth}
value={["Custom response"]}
onChange={onChange}
/>
);
const otherCheckbox = screen.getByRole("checkbox", { name: "Other" });
expect(otherCheckbox).toBeChecked();
expect(screen.getByDisplayValue("Custom response")).toBeInTheDocument();
const noneCheckbox = screen.getByRole("checkbox", { name: "None of the above" });
await userEvent.click(noneCheckbox);
expect(onChange).toHaveBeenCalledWith({ q1: ["None of the above"] });
});
test("allows deselecting 'None' option", async () => {
const onChange = vi.fn();
const questionWithNone = {
...defaultProps.question,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "none", label: { en: "None of the above" } },
],
} as TSurveyMultipleChoiceQuestion;
render(
<MultipleChoiceMultiQuestion
{...defaultProps}
question={questionWithNone}
value={["None of the above"]}
onChange={onChange}
/>
);
const noneCheckbox = screen.getByRole("checkbox", { name: "None of the above" });
expect(noneCheckbox).toBeChecked();
await userEvent.click(noneCheckbox);
expect(onChange).toHaveBeenCalledWith({ q1: [] });
});
test("handles keyboard accessibility for 'None' option with spacebar", async () => {
const onChange = vi.fn();
const questionWithNone = {
...defaultProps.question,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "none", label: { en: "None of the above" } },
],
} as TSurveyMultipleChoiceQuestion;
render(<MultipleChoiceMultiQuestion {...defaultProps} question={questionWithNone} onChange={onChange} />);
const noneLabel = screen.getByText("None of the above").closest("label");
expect(noneLabel).toBeInTheDocument();
fireEvent.keyDown(noneLabel!, { key: " " });
expect(onChange).toHaveBeenCalledWith({ q1: ["None of the above"] });
});
test("'None' option is checked when value matches", () => {
const questionWithNone = {
...defaultProps.question,
choices: [
{ id: "c1", label: { en: "Option 1" } },
{ id: "none", label: { en: "None of the above" } },
],
} as TSurveyMultipleChoiceQuestion;
render(
<MultipleChoiceMultiQuestion
{...defaultProps}
question={questionWithNone}
value={["None of the above"]}
/>
);
const noneCheckbox = screen.getByRole("checkbox", { name: "None of the above" });
expect(noneCheckbox).toBeChecked();
});
});

View File

@@ -1,3 +1,6 @@
import { useCallback, 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 { useCallback, 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 MultipleChoiceMultiProps {
question: TSurveyMultipleChoiceQuestion;
@@ -97,9 +97,24 @@ export function MultipleChoiceMultiQuestion({
[question.choices]
);
const noneOption = useMemo(
() => question.choices.find((choice) => choice.id === "none"),
[question.choices]
);
const otherSpecify = useRef<HTMLInputElement | null>(null);
const choicesContainerRef = useRef<HTMLDivElement | null>(null);
// Check if "none" option is selected
const isNoneSelected = useMemo(
() => Boolean(noneOption && value.includes(getLocalizedValue(noneOption.label, languageCode))),
[noneOption, value, languageCode]
);
// Common label className for all choice types
const baseLabelClassName =
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none";
useEffect(() => {
// Scroll to the bottom of choices container and focus on 'otherSpecify' input when 'otherSelected' is true
if (otherSelected && choicesContainerRef.current && otherSpecify.current) {
@@ -110,6 +125,7 @@ export function MultipleChoiceMultiQuestion({
const addItem = (item: string) => {
const isOtherValue = !questionChoiceLabels.includes(item);
if (Array.isArray(value)) {
if (isOtherValue) {
const newValue = value.filter((v) => {
@@ -175,7 +191,7 @@ export function MultipleChoiceMultiQuestion({
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-space-y-2" ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => {
if (!choice || choice.id === "other") return;
if (!choice || choice.id === "other" || choice.id === "none") return;
return (
<label
key={choice.id}
@@ -184,14 +200,14 @@ export function MultipleChoiceMultiQuestion({
value.includes(getLocalizedValue(choice.label, languageCode))
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
"fb-text-heading focus-within:fb-border-brand hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
isNoneSelected ? "fb-opacity-50" : "",
baseLabelClassName
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
autoFocus={idx === 0 && autoFocusEnabled}>
@@ -205,6 +221,7 @@ export function MultipleChoiceMultiQuestion({
value={getLocalizedValue(choice.label, languageCode)}
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`}
disabled={isNoneSelected}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
addItem(getLocalizedValue(choice.label, languageCode));
@@ -229,15 +246,18 @@ export function MultipleChoiceMultiQuestion({
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
otherSelected
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
isNoneSelected ? "fb-opacity-50" : "",
baseLabelClassName
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
if (otherSelected) return;
e.preventDefault();
document.getElementById(otherOption.id)?.click();
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
@@ -250,6 +270,7 @@ export function MultipleChoiceMultiQuestion({
value={getLocalizedValue(otherOption.label, languageCode)}
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`}
disabled={isNoneSelected}
onChange={() => {
if (otherSelected) {
setOtherValue("");
@@ -304,6 +325,51 @@ export function MultipleChoiceMultiQuestion({
) : null}
</label>
) : null}
{noneOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
isNoneSelected
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
baseLabelClassName
)}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
document.getElementById(noneOption.id)?.click();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={-1}
id={noneOption.id}
name={question.id}
value={getLocalizedValue(noneOption.label, languageCode)}
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={`${noneOption.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
setOtherSelected(false);
setOtherValue("");
onChange({ [question.id]: [getLocalizedValue(noneOption.label, languageCode)] });
} else {
removeItem(getLocalizedValue(noneOption.label, languageCode));
}
}}
checked={isNoneSelected}
/>
<span
id={`${noneOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(noneOption.label, languageCode)}
</span>
</span>
</label>
) : null}
</div>
</fieldset>
</div>

View File

@@ -338,6 +338,53 @@ describe("MultipleChoiceSingleQuestion", () => {
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("renders and allows selecting 'None' option", async () => {
const user = userEvent.setup();
const questionWithNone = {
...mockQuestion,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
{ id: "none", label: { default: "None of the above" } },
],
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithNone} />);
const noneRadio = screen.getByLabelText("None of the above");
expect(noneRadio).toBeInTheDocument();
await user.click(noneRadio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: "None of the above" });
});
test("'None' option clears otherSelected state", async () => {
const user = userEvent.setup();
const questionWithBoth = {
...mockQuestion,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "other", label: { default: "Other" } },
{ id: "none", label: { default: "None of the above" } },
],
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithBoth} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
mockOnChange.mockClear();
const noneRadio = screen.getByLabelText("None of the above");
await user.click(noneRadio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: "None of the above" });
});
test("handles spacebar key press on 'Other' option label when not selected", async () => {
const user = userEvent.setup();
@@ -371,6 +418,27 @@ describe("MultipleChoiceSingleQuestion", () => {
expect(mockOnChange).not.toHaveBeenCalled();
});
test("handles spacebar key press on 'None' option label", async () => {
const user = userEvent.setup();
const questionWithNone = {
...mockQuestion,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "none", label: { default: "None of the above" } },
],
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithNone} />);
const noneLabel = screen.getByLabelText("None of the above").closest("label");
if (noneLabel) {
await user.type(noneLabel, " ");
}
expect(mockOnChange).toHaveBeenCalledWith({ q1: "None of the above" });
});
test("displays custom other option placeholder when provided", async () => {
const user = userEvent.setup();
const questionWithCustomPlaceholder = {
@@ -385,4 +453,187 @@ describe("MultipleChoiceSingleQuestion", () => {
expect(screen.getByPlaceholderText("Custom placeholder text")).toBeInTheDocument();
});
test("displays default placeholder when otherOptionPlaceholder is empty", async () => {
const user = userEvent.setup();
const questionWithEmptyPlaceholder = {
...mockQuestion,
otherOptionPlaceholder: { default: "" },
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithEmptyPlaceholder} />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
});
test("'None' option is checked when value matches", () => {
const questionWithNone = {
...mockQuestion,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "none", label: { default: "None of the above" } },
],
};
render(
<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithNone} value="None of the above" />
);
const noneRadio = screen.getByLabelText("None of the above");
expect(noneRadio).toBeChecked();
});
test("displays video content when available", () => {
const questionWithVideo = {
...mockQuestion,
videoUrl: "https://example.com/video.mp4",
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithVideo} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("handles auto focus on first choice", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} autoFocusEnabled={true} />);
const firstChoiceLabel = screen.getByLabelText("Choice 1").closest("label");
expect(firstChoiceLabel).toHaveAttribute("autoFocus");
});
test("handles direction prop correctly", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} dir="rtl" />);
const choice1Radio = screen.getByLabelText("Choice 1");
expect(choice1Radio).toHaveAttribute("dir", "rtl");
});
test("handles prefilled answer from URL for 'Other' option", () => {
const mockGet = vi.fn().mockReturnValue("Other");
const mockURLSearchParams = vi.fn(() => ({
get: mockGet,
}));
global.URLSearchParams = mockURLSearchParams as any;
render(<MultipleChoiceSingleQuestion {...defaultProps} isFirstQuestion={true} value={undefined} />);
expect(mockURLSearchParams).toHaveBeenCalledWith(window.location.search);
expect(mockGet).toHaveBeenCalledWith("q1");
});
test("handles spacebar key press on regular choice label", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const choice1Label = screen.getByLabelText("Choice 1").closest("label");
if (choice1Label) {
await user.type(choice1Label, " ");
}
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("handles tabIndex correctly for different question states", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} currentQuestionId="q2" />);
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toHaveAttribute("tabIndex", "-1");
const backButton = screen.getByTestId("back-button");
expect(backButton).toHaveAttribute("tabIndex", "-1");
});
test("handles other option input with maxLength constraint", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
const otherInput = screen.getByPlaceholderText("Please specify");
expect(otherInput).toHaveAttribute("maxLength", "250");
});
test("handles other option input with pattern validation", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
const otherInput = screen.getByPlaceholderText("Please specify");
expect(otherInput).toHaveAttribute("pattern", ".*\\S+.*");
});
test("handles shuffle option 'all'", () => {
const questionWithShuffle = {
...mockQuestion,
shuffleOption: "all" as const,
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithShuffle} />);
expect(screen.getByLabelText("Choice 1")).toBeInTheDocument();
expect(screen.getByLabelText("Choice 2")).toBeInTheDocument();
});
test("handles shuffle option 'exceptLast'", () => {
const questionWithShuffle = {
...mockQuestion,
shuffleOption: "exceptLast" as const,
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithShuffle} />);
expect(screen.getByLabelText("Choice 1")).toBeInTheDocument();
expect(screen.getByLabelText("Choice 2")).toBeInTheDocument();
});
test("handles shuffle option 'none'", () => {
const questionWithNoShuffle = {
...mockQuestion,
shuffleOption: "none" as const,
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithNoShuffle} />);
expect(screen.getByLabelText("Choice 1")).toBeInTheDocument();
expect(screen.getByLabelText("Choice 2")).toBeInTheDocument();
});
test("handles other option input direction when value is set", () => {
render(<MultipleChoiceSingleQuestion {...defaultProps} value="Some text" dir="rtl" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
expect(otherRadio).toHaveAttribute("dir", "rtl");
});
test("handles back button click with TTC update", 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("handles form submission with TTC update", 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));
expect(mockSetTtc).toHaveBeenCalled();
});
});

View File

@@ -78,6 +78,11 @@ export function MultipleChoiceSingleQuestion({
[question.choices]
);
const noneOption = useMemo(
() => question.choices.find((choice) => choice.id === "none"),
[question.choices]
);
useEffect(() => {
if (isFirstQuestion && !value) {
const prefillAnswer = new URLSearchParams(window.location.search).get(question.id);
@@ -134,7 +139,7 @@ export function MultipleChoiceSingleQuestion({
role="radiogroup"
ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => {
if (!choice || choice.id === "other") return;
if (!choice || choice.id === "other" || choice.id === "none") return;
return (
<label
key={choice.id}
@@ -255,6 +260,53 @@ export function MultipleChoiceSingleQuestion({
) : null}
</label>
) : null}
{noneOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(noneOption.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(noneOption.id)?.click();
document.getElementById(noneOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir={dir}
type="radio"
id={noneOption.id}
name={question.id}
value={getLocalizedValue(noneOption.label, languageCode)}
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={`${noneOption.id}-label`}
onClick={() => {
const noneValue = getLocalizedValue(noneOption.label, languageCode);
if (!question.required && value === noneValue) {
onChange({ [question.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [question.id]: noneValue });
}
}}
checked={value === getLocalizedValue(noneOption.label, languageCode)}
/>
<span
id={`${noneOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(noneOption.label, languageCode)}
</span>
</span>
</label>
) : null}
</div>
</fieldset>
</div>

View File

@@ -1,4 +1,3 @@
import { getLocalizedValue } from "@/lib/i18n";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import {
@@ -10,6 +9,7 @@ import {
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n";
const getVariableValue = (
variables: TSurveyVariable[],
@@ -107,7 +107,7 @@ const getLeftOperandValue = (
}
if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") {
const isOthersEnabled = currentQuestion.choices.at(-1)?.id === "other";
const isOthersEnabled = currentQuestion.choices.some((c) => c.id === "other");
if (typeof responseValue === "string") {
const choice = currentQuestion.choices.find((choice) => {

View File

@@ -1,4 +1,3 @@
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -10,6 +9,7 @@ import {
type TSurveyQuestion,
type TSurveyQuestionChoice,
} from "@formbricks/types/surveys/types";
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
@@ -51,8 +51,11 @@ export const getShuffledChoicesIds = (
const otherOption = choices.find((choice) => {
return choice.id === "other";
});
const noneOption = choices.find((choice) => {
return choice.id === "none";
});
const shuffledChoices = otherOption ? [...choices.filter((choice) => choice.id !== "other")] : [...choices];
const shuffledChoices = choices.filter((choice) => choice.id !== "other" && choice.id !== "none");
if (shuffleOption === "all") {
shuffle(shuffledChoices);
@@ -68,6 +71,9 @@ export const getShuffledChoicesIds = (
if (otherOption) {
shuffledChoices.push(otherOption);
}
if (noneOption) {
shuffledChoices.push(noneOption);
}
return shuffledChoices.map((choice) => choice.id);
};