From 18f4cd977d56ff34a90ed370e8717ef9c65dd5b7 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Fri, 10 Oct 2025 07:50:45 -0700 Subject: [PATCH] feat: Add "None of the above" option for Multi-Select and Single-Select questions (#6646) --- .../(analysis)/summary/lib/surveySummary.ts | 56 +- apps/web/locales/de-DE.json | 4 +- apps/web/locales/en-US.json | 4 +- apps/web/locales/fr-FR.json | 4 +- apps/web/locales/ja-JP.json | 4 +- apps/web/locales/pt-BR.json | 4 +- apps/web/locales/pt-PT.json | 4 +- apps/web/locales/ro-RO.json | 4 +- apps/web/locales/zh-Hans-CN.json | 4 +- apps/web/locales/zh-Hant-TW.json | 4 +- apps/web/modules/auth/lib/authOptions.ts | 24 +- .../multiple-choice-question-form.test.tsx | 1081 ++++++++++++++++- .../multiple-choice-question-form.tsx | 112 +- .../components/question-option-choice.tsx | 34 +- apps/web/modules/survey/editor/lib/utils.tsx | 30 +- .../multiple-choice-multi-question.test.tsx | 145 +++ .../multiple-choice-multi-question.tsx | 84 +- .../multiple-choice-single-question.test.tsx | 251 ++++ .../multiple-choice-single-question.tsx | 54 +- packages/surveys/src/lib/logic.ts | 4 +- packages/surveys/src/lib/utils.ts | 10 +- 21 files changed, 1776 insertions(+), 145 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 02941b8f3d..8e774947f5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -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, 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, diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 37350f155e..561e3b746c 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index edb67d0878..d252aaae1e 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 0e0257d8e9..ee1dd12ea2 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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}\"", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 844e52f470..81a3e42fe2 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -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}\" クォータ で使用されています", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 2d5ec2596c..35c6bd3b47 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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}\"", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index dffb44a999..8882775e0f 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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}\"", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index a33a708c63..025c5b5a18 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -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}\"", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 997026403c..53e65dd604 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -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}\" 配额 使用", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 3530979845..9acc443c41 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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}\" 配額中", diff --git a/apps/web/modules/auth/lib/authOptions.ts b/apps/web/modules/auth/lib/authOptions.ts index 340829fa1a..df65e660ca 100644 --- a/apps/web/modules/auth/lib/authOptions.ts +++ b/apps/web/modules/auth/lib/authOptions.ts @@ -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 diff --git a/apps/web/modules/survey/editor/components/multiple-choice-question-form.test.tsx b/apps/web/modules/survey/editor/components/multiple-choice-question-form.test.tsx index 635589e11f..eeec5f86a4 100755 --- a/apps/web/modules/survey/editor/components/multiple-choice-question-form.test.tsx +++ b/apps/web/modules/survey/editor/components/multiple-choice-question-form.test.tsx @@ -1,11 +1,51 @@ +import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { TSurveyMultipleChoiceQuestion } from "@formbricks/types/surveys/types"; +import { TLanguage } from "@formbricks/types/project"; +import { + TSurvey, + TSurveyLanguage, + TSurveyMultipleChoiceQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; import { MultipleChoiceQuestionForm } from "./multiple-choice-question-form"; vi.mock("@/modules/survey/components/question-form-input", () => ({ QuestionFormInput: vi.fn((props) => ( - {}} /> + {}} + /> + )), +})); + +vi.mock("@/modules/survey/editor/components/question-option-choice", () => ({ + QuestionOptionChoice: vi.fn(({ choice, addChoice, deleteChoice, choiceIdx }) => ( +
+ {choice.label.default} + + +
+ )), +})); + +vi.mock("@/modules/ui/components/shuffle-option-select", () => ({ + ShuffleOptionSelect: vi.fn(({ shuffleOption, updateQuestion, questionIdx }) => ( + )), })); @@ -14,22 +54,59 @@ vi.mock("@formkit/auto-animate/react", () => ({ })); vi.mock("@dnd-kit/core", () => ({ - DndContext: ({ children }) => <>{children}, + DndContext: ({ children, onDragEnd }: any) => ( +
+ {children} +
+ ), })); vi.mock("@dnd-kit/sortable", () => ({ - SortableContext: ({ children }) => <>{children}, - useSortable: () => ({ - attributes: {}, - listeners: {}, - setNodeRef: () => {}, - transform: null, - transition: null, - }), - verticalListSortingStrategy: () => {}, + SortableContext: ({ children }: any) => <>{children}, + verticalListSortingStrategy: {}, +})); + +vi.mock("@/modules/survey/editor/lib/utils", () => ({ + findOptionUsedInLogic: vi.fn(() => -1), +})); + +vi.mock("@paralleldrive/cuid2", () => ({ + createId: vi.fn(() => "test-id-" + Math.random().toString(36).substring(2, 9)), })); describe("MultipleChoiceQuestionForm", () => { + const mockUpdateQuestion = vi.fn(); + const mockSetSelectedLanguageCode = vi.fn(); + + const createMockSurvey = (): TSurvey => + ({ + id: "survey1", + name: "Test Survey", + type: "link", + languages: [ + { + language: { code: "default" } as unknown as TLanguage, + default: true, + } as unknown as TSurveyLanguage, + ], + questions: [], + createdAt: new Date("2024-01-01T00:00:00.000Z"), + environmentId: "env123", + }) as unknown as TSurvey; + + const createMockQuestion = (overrides?: Partial) => ({ + id: "q1", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Test Question" }, + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + shuffleOption: "none", + required: false, + ...overrides, + }); + beforeEach(() => { Object.defineProperty(window, "matchMedia", { writable: true, @@ -44,6 +121,7 @@ describe("MultipleChoiceQuestionForm", () => { dispatchEvent: vi.fn(), })), }); + vi.clearAllMocks(); }); afterEach(() => { @@ -51,33 +129,982 @@ describe("MultipleChoiceQuestionForm", () => { }); test("should render the question headline input field with the correct label and value", () => { - const question = { - id: "1", - type: "multipleChoiceSingle", - headline: { default: "Test Headline" }, - choices: [], - } as unknown as TSurveyMultipleChoiceQuestion; - const localSurvey = { - id: "survey1", - languages: [{ language: { code: "default" }, default: true }], - } as any; + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); render( ); - const questionFormInput = screen.getByTestId("question-form-input"); - expect(questionFormInput).toBeDefined(); - expect(questionFormInput).toHaveValue("Test Headline"); + const headlineInput = screen.getByTestId("question-form-input-headline"); + expect(headlineInput).toBeDefined(); + expect(headlineInput).toHaveValue("Test Question"); + }); + + test("should render all choices", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + expect(screen.getByTestId("question-option-choice-c1")).toBeDefined(); + expect(screen.getByTestId("question-option-choice-c2")).toBeDefined(); + }); + + test("should render subheader when it exists", () => { + const question = createMockQuestion({ + subheader: { default: "Test Description" }, + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const subheaderInput = screen.getByTestId("question-form-input-subheader"); + expect(subheaderInput).toBeDefined(); + expect(subheaderInput).toHaveValue("Test Description"); + }); + + test("should show add description button when subheader is undefined", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addDescriptionButton = screen.getByText("environments.surveys.edit.add_description"); + expect(addDescriptionButton).toBeDefined(); + }); + + test("should add subheader when add description button is clicked", async () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addDescriptionButton = screen.getByText("environments.surveys.edit.add_description"); + await userEvent.click(addDescriptionButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + subheader: { default: "" }, + }); + }); + + test("should show 'Add Other' button when 'other' choice is not present", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addOtherButton = screen.getByText("environments.surveys.edit.add_other"); + expect(addOtherButton).toBeDefined(); + }); + + test("should not show 'Add Other' button when 'other' choice is present", () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "other", label: { default: "Other" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addOtherButton = screen.queryByText("environments.surveys.edit.add_other"); + expect(addOtherButton).toBeNull(); + }); + + test("should add 'other' choice when 'Add Other' button is clicked", async () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addOtherButton = screen.getByText("environments.surveys.edit.add_other"); + await userEvent.click(addOtherButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + { id: "other", label: { default: "common.other" } }, + ], + }); + }); + + test("should show 'Add None of the above' button when 'none' choice is not present", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.getByText("environments.surveys.edit.add_none_of_the_above"); + expect(addNoneButton).toBeDefined(); + }); + + test("should not show 'Add None of the above' button when 'none' choice is present", () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "none", label: { default: "None of the above" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.queryByText("environments.surveys.edit.add_none_of_the_above"); + expect(addNoneButton).toBeNull(); + }); + + test("should add 'none' choice when 'Add None of the above' button is clicked", async () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.getByText("environments.surveys.edit.add_none_of_the_above"); + await userEvent.click(addNoneButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + { id: "none", label: { default: "common.none_of_the_above" } }, + ], + }); + }); + + test("should convert from single to multi choice", async () => { + const question = createMockQuestion({ + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const convertButton = screen.getByText("environments.surveys.edit.convert_to_multiple_choice"); + await userEvent.click(convertButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + }); + }); + + test("should convert from multi to single choice", async () => { + const question = createMockQuestion({ + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const convertButton = screen.getByText("environments.surveys.edit.convert_to_single_choice"); + await userEvent.click(convertButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + }); + }); + + test("should render shuffle option select", () => { + const question = createMockQuestion({ + shuffleOption: "none", + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const shuffleSelect = screen.getByTestId("shuffle-option-select"); + expect(shuffleSelect).toBeDefined(); + expect(shuffleSelect).toHaveValue("none"); + }); + + test("should change shuffleOption to 'exceptLast' when adding 'other' with 'all' shuffleOption", async () => { + const question = createMockQuestion({ + shuffleOption: "all", + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addOtherButton = screen.getByText("environments.surveys.edit.add_other"); + await userEvent.click(addOtherButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: expect.any(Array), + shuffleOption: "exceptLast", + }); + }); + + test("should change shuffleOption to 'exceptLast' when adding 'none' with 'all' shuffleOption", async () => { + const question = createMockQuestion({ + shuffleOption: "all", + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.getByText("environments.surveys.edit.add_none_of_the_above"); + await userEvent.click(addNoneButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: expect.any(Array), + shuffleOption: "exceptLast", + }); + }); + + test("should maintain order with 'other' at the end when adding it", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addOtherButton = screen.getByText("environments.surveys.edit.add_other"); + await userEvent.click(addOtherButton); + + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + expect(updatedChoices[updatedChoices.length - 1].id).toBe("other"); + }); + + test("should maintain order with 'none' at the end when adding it", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.getByText("environments.surveys.edit.add_none_of_the_above"); + await userEvent.click(addNoneButton); + + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + expect(updatedChoices[updatedChoices.length - 1].id).toBe("none"); + }); + + test("should maintain order with both 'other' and 'none' at the end", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + { id: "other", label: { default: "Other" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.getByText("environments.surveys.edit.add_none_of_the_above"); + await userEvent.click(addNoneButton); + + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + expect(updatedChoices[updatedChoices.length - 2].id).toBe("other"); + expect(updatedChoices[updatedChoices.length - 1].id).toBe("none"); + }); + + test("should not add 'other' if it already exists", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "other", label: { default: "Other" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addOtherButton = screen.queryByText("environments.surveys.edit.add_other"); + expect(addOtherButton).toBeNull(); + }); + + test("should not add 'none' if it already exists", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "none", label: { default: "None of the above" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addNoneButton = screen.queryByText("environments.surveys.edit.add_none_of_the_above"); + expect(addNoneButton).toBeNull(); + }); + + test("should handle deleting a choice", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + { id: "c3", label: { default: "Choice 3" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const deleteButton = screen.getByTestId("delete-choice-c2"); + await userEvent.click(deleteButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c3", label: { default: "Choice 3" } }, + ], + }); + }); + + test("should show error toast when deleting a choice used in logic", async () => { + const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils"); + vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(2); + + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + { id: "c3", label: { default: "Choice 3" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const deleteButton = screen.getByTestId("delete-choice-c1"); + await userEvent.click(deleteButton); + + expect(toast.error).toHaveBeenCalled(); + expect(mockUpdateQuestion).not.toHaveBeenCalled(); + }); + + test("should initialize new choices with empty strings for all configured survey languages", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1", de: "Auswahl 1" } }, + { id: "c2", label: { default: "Choice 2", de: "Auswahl 2" } }, + ], + }); + const localSurvey = { + id: "survey1", + name: "Test Survey", + type: "link", + languages: [ + { + language: { code: "default" } as unknown as TLanguage, + default: true, + } as unknown as TSurveyLanguage, + { + language: { code: "de" } as unknown as TLanguage, + default: false, + } as unknown as TSurveyLanguage, + ], + questions: [], + createdAt: new Date("2024-01-01T00:00:00.000Z"), + environmentId: "env123", + } as unknown as TSurvey; + + render( + + ); + + const addChoiceButton = screen.getByTestId("add-choice-c1"); + await userEvent.click(addChoiceButton); + + expect(mockUpdateQuestion).toHaveBeenCalled(); + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + const newChoice = updatedChoices[1]; + expect(newChoice.label).toEqual({ default: "", de: "" }); + }); + + test("should render DndContext for drag and drop", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + const dndContext = screen.getByTestId("dnd-context"); + expect(dndContext).toBeDefined(); + }); + + test("should handle adding choice at specific index", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const addChoiceButton = screen.getByTestId("add-choice-c1"); + await userEvent.click(addChoiceButton); + + expect(mockUpdateQuestion).toHaveBeenCalled(); + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + expect(updatedChoices).toHaveLength(3); + expect(updatedChoices[1].id).toMatch(/^test-id-/); + }); + + test("should add 'Other' with multi-language support", async () => { + const question = createMockQuestion(); + const localSurvey = { + id: "survey1", + name: "Test Survey", + type: "link", + languages: [ + { + language: { code: "default" } as unknown as TLanguage, + default: true, + } as unknown as TSurveyLanguage, + { + language: { code: "de" } as unknown as TLanguage, + default: false, + } as unknown as TSurveyLanguage, + ], + questions: [], + createdAt: new Date("2024-01-01T00:00:00.000Z"), + environmentId: "env123", + } as unknown as TSurvey; + + render( + + ); + + const addOtherButton = screen.getByText("environments.surveys.edit.add_other"); + await userEvent.click(addOtherButton); + + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + const otherChoice = updatedChoices.find((c: any) => c.id === "other"); + expect(otherChoice.label).toHaveProperty("default"); + expect(otherChoice.label).toHaveProperty("de"); + }); + + test("should add 'None' with multi-language support", async () => { + const question = createMockQuestion(); + const localSurvey = { + id: "survey1", + name: "Test Survey", + type: "link", + languages: [ + { + language: { code: "default" } as unknown as TLanguage, + default: true, + } as unknown as TSurveyLanguage, + { + language: { code: "de" } as unknown as TLanguage, + default: false, + } as unknown as TSurveyLanguage, + ], + questions: [], + createdAt: new Date("2024-01-01T00:00:00.000Z"), + environmentId: "env123", + } as unknown as TSurvey; + + render( + + ); + + const addNoneButton = screen.getByText("environments.surveys.edit.add_none_of_the_above"); + await userEvent.click(addNoneButton); + + const updatedChoices = mockUpdateQuestion.mock.calls[0][1].choices; + const noneChoice = updatedChoices.find((c: any) => c.id === "none"); + expect(noneChoice.label).toHaveProperty("default"); + expect(noneChoice.label).toHaveProperty("de"); + }); + + test("should pass isInvalid prop to QuestionFormInput", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + expect(screen.getByTestId("question-form-input-headline")).toBeDefined(); + }); + + test("should pass isStorageConfigured prop to QuestionFormInput", () => { + const question = createMockQuestion(); + const localSurvey = createMockSurvey(); + + render( + + ); + + expect(screen.getByTestId("question-form-input-headline")).toBeDefined(); + }); + + test("should handle empty choices array", () => { + const question = createMockQuestion({ + choices: [], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + expect(screen.queryByTestId(/^question-option-choice-/)).toBeNull(); + }); + + test("should delete 'other' choice without logic check", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "other", label: { default: "Other" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const deleteButton = screen.getByTestId("delete-choice-other"); + await userEvent.click(deleteButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: [{ id: "c1", label: { default: "Choice 1" } }], + }); + }); + + test("should delete 'none' choice without logic check", async () => { + const question = createMockQuestion({ + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "none", label: { default: "None of the above" } }, + ], + }); + const localSurvey = createMockSurvey(); + + render( + + ); + + const deleteButton = screen.getByTestId("delete-choice-none"); + await userEvent.click(deleteButton); + + expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { + choices: [{ id: "c1", label: { default: "Choice 1" } }], + }); }); }); diff --git a/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx index 1502b9a45f..7d39d3572b 100644 --- a/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx +++ b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx @@ -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 = ({
- {question.choices.filter((c) => c.id === "other").length === 0 && ( - - )} +
+ {specialChoices.map((specialChoice) => { + if (question.choices.some((c) => c.id === specialChoice.id)) return null; + return ( + + ); + })} +
diff --git a/packages/surveys/src/components/questions/multiple-choice-single-question.test.tsx b/packages/surveys/src/components/questions/multiple-choice-single-question.test.tsx index e57e9060e8..c08878aed9 100644 --- a/packages/surveys/src/components/questions/multiple-choice-single-question.test.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-single-question.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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( + + ); + + 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(); + expect(screen.getByTestId("question-media")).toBeInTheDocument(); + }); + + test("handles auto focus on first choice", () => { + render(); + + const firstChoiceLabel = screen.getByLabelText("Choice 1").closest("label"); + expect(firstChoiceLabel).toHaveAttribute("autoFocus"); + }); + + test("handles direction prop correctly", () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByLabelText("Choice 1")).toBeInTheDocument(); + expect(screen.getByLabelText("Choice 2")).toBeInTheDocument(); + }); + + test("handles other option input direction when value is set", () => { + render(); + + 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(); + + 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(); + + const submitButton = screen.getByTestId("submit-button"); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith({ q1: "Choice 1" }, expect.any(Object)); + expect(mockSetTtc).toHaveBeenCalled(); + }); }); diff --git a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx index 429819171c..e27d1e16fa 100644 --- a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx @@ -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 (