mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
feat: Add "None of the above" option for Multi-Select and Single-Select questions (#6646)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\" クォータ で使用されています",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\" 配额 使用",
|
||||
|
||||
@@ -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}\" 配額中",
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user