mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-19 13:29:08 -06:00
feat: advanced matrix question logic (#5408)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
1169
apps/web/lib/surveyLogic/utils.test.ts
Normal file
1169
apps/web/lib/surveyLogic/utils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -457,9 +457,17 @@ const evaluateSingleCondition = (
|
||||
return values.length > 0 && !values.includes("");
|
||||
} else return false;
|
||||
case "isSet":
|
||||
case "isNotEmpty":
|
||||
return leftValue !== undefined && leftValue !== null && leftValue !== "";
|
||||
case "isNotSet":
|
||||
return leftValue === undefined || leftValue === null || leftValue === "";
|
||||
case "isEmpty":
|
||||
return leftValue === "";
|
||||
case "isAnyOf":
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string") {
|
||||
return rightValue.includes(leftValue);
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -533,6 +541,33 @@ const getLeftOperandValue = (
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentQuestion.type === "matrix" &&
|
||||
typeof responseValue === "object" &&
|
||||
!Array.isArray(responseValue)
|
||||
) {
|
||||
if (leftOperand.meta && leftOperand.meta.row !== undefined) {
|
||||
const rowIndex = Number(leftOperand.meta.row);
|
||||
|
||||
if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) {
|
||||
return undefined;
|
||||
}
|
||||
const row = getLocalizedValue(currentQuestion.rows[rowIndex], selectedLanguage);
|
||||
|
||||
const rowValue = responseValue[row];
|
||||
if (rowValue === "") return "";
|
||||
|
||||
if (rowValue) {
|
||||
const columnIndex = currentQuestion.columns.findIndex((column) => {
|
||||
return getLocalizedValue(column, selectedLanguage) === rowValue;
|
||||
});
|
||||
if (columnIndex === -1) return undefined;
|
||||
return columnIndex.toString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return data[leftOperand.value];
|
||||
case "variable":
|
||||
const variables = localSurvey.variables || [];
|
||||
|
||||
@@ -1326,6 +1326,7 @@
|
||||
"close_survey_on_date": "Umfrage am Datum schließen",
|
||||
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
|
||||
"color": "Farbe",
|
||||
"column_used_in_logic_error": "Diese Spalte wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
|
||||
"columns": "Spalten",
|
||||
"company": "Firma",
|
||||
"company_logo": "Firmenlogo",
|
||||
@@ -1452,10 +1453,14 @@
|
||||
"invalid_youtube_url": "Ungültige YouTube-URL",
|
||||
"is_accepted": "Ist akzeptiert",
|
||||
"is_after": "Ist nach",
|
||||
"is_any_of": "Ist eine von",
|
||||
"is_before": "Ist vor",
|
||||
"is_booked": "Ist gebucht",
|
||||
"is_clicked": "Wird geklickt",
|
||||
"is_completely_submitted": "Vollständig eingereicht",
|
||||
"is_empty": "Ist leer",
|
||||
"is_not": "Ist nicht",
|
||||
"is_not_empty": "Ist nicht leer",
|
||||
"is_not_set": "Ist nicht festgelegt",
|
||||
"is_partially_submitted": "Teilweise eingereicht",
|
||||
"is_set": "Ist festgelegt",
|
||||
@@ -1487,6 +1492,7 @@
|
||||
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.",
|
||||
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
|
||||
"no_option_found": "Keine Option gefunden",
|
||||
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
|
||||
"number": "Nummer",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.",
|
||||
@@ -1538,6 +1544,7 @@
|
||||
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
|
||||
"response_options": "Antwortoptionen",
|
||||
"roundness": "Rundheit",
|
||||
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
|
||||
"rows": "Zeilen",
|
||||
"save_and_close": "Speichern & Schließen",
|
||||
"scale": "Scale",
|
||||
|
||||
@@ -1326,6 +1326,7 @@
|
||||
"close_survey_on_date": "Close survey on date",
|
||||
"close_survey_on_response_limit": "Close survey on response limit",
|
||||
"color": "Color",
|
||||
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"columns": "Columns",
|
||||
"company": "Company",
|
||||
"company_logo": "Company logo",
|
||||
@@ -1452,10 +1453,14 @@
|
||||
"invalid_youtube_url": "Invalid YouTube URL",
|
||||
"is_accepted": "Is accepted",
|
||||
"is_after": "Is after",
|
||||
"is_any_of": "Is any of",
|
||||
"is_before": "Is before",
|
||||
"is_booked": "Is booked",
|
||||
"is_clicked": "Is clicked",
|
||||
"is_completely_submitted": "Is completely submitted",
|
||||
"is_empty": "Is empty",
|
||||
"is_not": "Is not",
|
||||
"is_not_empty": "Is not empty",
|
||||
"is_not_set": "Is not set",
|
||||
"is_partially_submitted": "Is partially submitted",
|
||||
"is_set": "Is set",
|
||||
@@ -1487,6 +1492,7 @@
|
||||
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
|
||||
"no_images_found_for": "No images found for ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
|
||||
"no_option_found": "No option found",
|
||||
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
|
||||
"number": "Number",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.",
|
||||
@@ -1538,6 +1544,7 @@
|
||||
"response_limits_redirections_and_more": "Response limits, redirections and more.",
|
||||
"response_options": "Response Options",
|
||||
"roundness": "Roundness",
|
||||
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"rows": "Rows",
|
||||
"save_and_close": "Save & Close",
|
||||
"scale": "Scale",
|
||||
|
||||
@@ -1326,6 +1326,7 @@
|
||||
"close_survey_on_date": "Clôturer l'enquête à la date",
|
||||
"close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse",
|
||||
"color": "Couleur",
|
||||
"column_used_in_logic_error": "Cette colonne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
|
||||
"columns": "Colonnes",
|
||||
"company": "Société",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
@@ -1452,10 +1453,14 @@
|
||||
"invalid_youtube_url": "URL YouTube invalide",
|
||||
"is_accepted": "C'est accepté",
|
||||
"is_after": "est après",
|
||||
"is_any_of": "Est l'un des",
|
||||
"is_before": "Est avant",
|
||||
"is_booked": "Est réservé",
|
||||
"is_clicked": "Est cliqué",
|
||||
"is_completely_submitted": "Est complètement soumis",
|
||||
"is_empty": "Est vide",
|
||||
"is_not": "N'est pas",
|
||||
"is_not_empty": "N'est pas vide",
|
||||
"is_not_set": "N'est pas défini",
|
||||
"is_partially_submitted": "Est partiellement soumis",
|
||||
"is_set": "Est défini",
|
||||
@@ -1487,6 +1492,7 @@
|
||||
"no_hidden_fields_yet_add_first_one_below": "Aucun champ caché pour le moment. Ajoutez le premier ci-dessous.",
|
||||
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
|
||||
"no_option_found": "Aucune option trouvée",
|
||||
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
|
||||
"number": "Numéro",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.",
|
||||
@@ -1538,6 +1544,7 @@
|
||||
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
|
||||
"response_options": "Options de réponse",
|
||||
"roundness": "Rondité",
|
||||
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
|
||||
"rows": "Lignes",
|
||||
"save_and_close": "Enregistrer et fermer",
|
||||
"scale": "Échelle",
|
||||
|
||||
@@ -1326,6 +1326,7 @@
|
||||
"close_survey_on_date": "Fechar pesquisa na data",
|
||||
"close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas",
|
||||
"color": "cor",
|
||||
"column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"columns": "colunas",
|
||||
"company": "empresa",
|
||||
"company_logo": "Logo da empresa",
|
||||
@@ -1452,10 +1453,14 @@
|
||||
"invalid_youtube_url": "URL do YouTube inválida",
|
||||
"is_accepted": "Está aceito",
|
||||
"is_after": "é depois",
|
||||
"is_any_of": "É qualquer um de",
|
||||
"is_before": "é antes",
|
||||
"is_booked": "Tá reservado",
|
||||
"is_clicked": "É clicado",
|
||||
"is_completely_submitted": "Está completamente submetido",
|
||||
"is_empty": "Está vazio",
|
||||
"is_not": "Não está",
|
||||
"is_not_empty": "Não está vazio",
|
||||
"is_not_set": "Não está definido",
|
||||
"is_partially_submitted": "Parcialmente enviado",
|
||||
"is_set": "Está definido",
|
||||
@@ -1487,6 +1492,7 @@
|
||||
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
|
||||
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
|
||||
"no_option_found": "Nenhuma opção encontrada",
|
||||
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
|
||||
"number": "Número",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.",
|
||||
@@ -1538,6 +1544,7 @@
|
||||
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
|
||||
"response_options": "Opções de Resposta",
|
||||
"roundness": "redondeza",
|
||||
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"rows": "linhas",
|
||||
"save_and_close": "Salvar e Fechar",
|
||||
"scale": "escala",
|
||||
|
||||
@@ -1326,6 +1326,7 @@
|
||||
"close_survey_on_date": "Encerrar inquérito na data",
|
||||
"close_survey_on_response_limit": "Fechar inquérito no limite de respostas",
|
||||
"color": "Cor",
|
||||
"column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"columns": "Colunas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo da empresa",
|
||||
@@ -1452,10 +1453,14 @@
|
||||
"invalid_youtube_url": "URL do YouTube inválido",
|
||||
"is_accepted": "É aceite",
|
||||
"is_after": "É depois",
|
||||
"is_any_of": "É qualquer um de",
|
||||
"is_before": "É antes",
|
||||
"is_booked": "Está reservado",
|
||||
"is_clicked": "É clicado",
|
||||
"is_completely_submitted": "Está completamente submetido",
|
||||
"is_empty": "Está vazio",
|
||||
"is_not": "Não está",
|
||||
"is_not_empty": "Não está vazio",
|
||||
"is_not_set": "Não está definido",
|
||||
"is_partially_submitted": "Está parcialmente submetido",
|
||||
"is_set": "Está definido",
|
||||
@@ -1487,6 +1492,7 @@
|
||||
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
|
||||
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
|
||||
"no_option_found": "Nenhuma opção encontrada",
|
||||
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
|
||||
"number": "Número",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.",
|
||||
@@ -1538,6 +1544,7 @@
|
||||
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
|
||||
"response_options": "Opções de Resposta",
|
||||
"roundness": "Arredondamento",
|
||||
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"rows": "Linhas",
|
||||
"save_and_close": "Guardar e Fechar",
|
||||
"scale": "Escala",
|
||||
|
||||
@@ -1326,6 +1326,7 @@
|
||||
"close_survey_on_date": "在指定日期關閉問卷",
|
||||
"close_survey_on_response_limit": "在回應次數上限關閉問卷",
|
||||
"color": "顏色",
|
||||
"column_used_in_logic_error": "此 column 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"columns": "欄位",
|
||||
"company": "公司",
|
||||
"company_logo": "公司標誌",
|
||||
@@ -1452,10 +1453,14 @@
|
||||
"invalid_youtube_url": "無效的 YouTube 網址",
|
||||
"is_accepted": "已接受",
|
||||
"is_after": "在之後",
|
||||
"is_any_of": "是任何一個",
|
||||
"is_before": "在之前",
|
||||
"is_booked": "已預訂",
|
||||
"is_clicked": "已點擊",
|
||||
"is_completely_submitted": "已完全提交",
|
||||
"is_empty": "是空的",
|
||||
"is_not": "不是",
|
||||
"is_not_empty": "不是空的",
|
||||
"is_not_set": "未設定",
|
||||
"is_partially_submitted": "已部分提交",
|
||||
"is_set": "已設定",
|
||||
@@ -1487,6 +1492,7 @@
|
||||
"no_hidden_fields_yet_add_first_one_below": "尚無隱藏欄位。在下方新增第一個隱藏欄位。",
|
||||
"no_images_found_for": "找不到「'{'query'}'」的圖片",
|
||||
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
|
||||
"no_option_found": "找不到選項",
|
||||
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
|
||||
"number": "數字",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。",
|
||||
@@ -1538,6 +1544,7 @@
|
||||
"response_limits_redirections_and_more": "回應限制、重新導向等。",
|
||||
"response_options": "回應選項",
|
||||
"roundness": "圓角",
|
||||
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"rows": "列",
|
||||
"save_and_close": "儲存並關閉",
|
||||
"scale": "比例",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
TSurvey,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorConditionsProps {
|
||||
@@ -136,10 +137,34 @@ export function LogicEditorConditions({
|
||||
};
|
||||
|
||||
const handleQuestionChange = (condition: TSingleCondition, value: string, option?: TComboboxOption) => {
|
||||
const type = option?.meta?.type as TDynamicLogicField;
|
||||
if (type === "question") {
|
||||
const [questionId, rowId] = value.split(".");
|
||||
const question = localSurvey.questions.find((q) => q.id === questionId);
|
||||
|
||||
if (question && question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (value.includes(".")) {
|
||||
// Matrix question with rowId is selected
|
||||
handleUpdateCondition(condition.id, {
|
||||
leftOperand: {
|
||||
value: questionId,
|
||||
type: "question",
|
||||
meta: {
|
||||
row: rowId,
|
||||
},
|
||||
},
|
||||
operator: "isEmpty",
|
||||
rightOperand: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateCondition(condition.id, {
|
||||
leftOperand: {
|
||||
value,
|
||||
type: option?.meta?.type as TDynamicLogicField,
|
||||
type,
|
||||
},
|
||||
operator: "isSkipped",
|
||||
rightOperand: undefined,
|
||||
@@ -184,6 +209,17 @@ export function LogicEditorConditions({
|
||||
}
|
||||
};
|
||||
|
||||
const getLeftOperandValue = (condition: TSingleCondition) => {
|
||||
if (condition.leftOperand.type === "question") {
|
||||
const question = localSurvey.questions.find((q) => q.id === condition.leftOperand.value);
|
||||
if (question && question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (condition.leftOperand?.meta?.row !== undefined) {
|
||||
return `${condition.leftOperand.value}.${condition.leftOperand.meta.row}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return condition.leftOperand.value;
|
||||
};
|
||||
const renderCondition = (
|
||||
condition: TSingleCondition | TConditionGroup,
|
||||
index: number,
|
||||
@@ -257,6 +293,7 @@ export function LogicEditorConditions({
|
||||
"includesOneOf",
|
||||
"doesNotIncludeOneOf",
|
||||
"doesNotIncludeAllOf",
|
||||
"isAnyOf",
|
||||
].includes(condition.operator);
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-x-2">
|
||||
@@ -279,7 +316,7 @@ export function LogicEditorConditions({
|
||||
key="conditionValue"
|
||||
showSearch={false}
|
||||
groupedOptions={conditionValueOptions}
|
||||
value={condition.leftOperand.value}
|
||||
value={getLeftOperandValue(condition)}
|
||||
onChangeValue={(val: string, option) => {
|
||||
handleQuestionChange(condition, val, option);
|
||||
}}
|
||||
|
||||
@@ -44,14 +44,14 @@ export function LogicEditor({
|
||||
value: string;
|
||||
}[] = [];
|
||||
|
||||
localSurvey.questions.forEach((ques) => {
|
||||
if (ques.id === question.id) return null;
|
||||
for (let i = questionIdx + 1; i < localSurvey.questions.length; i++) {
|
||||
const ques = localSurvey.questions[i];
|
||||
options.push({
|
||||
icon: QUESTIONS_ICON_MAP[ques.type],
|
||||
label: getLocalizedValue(ques.headline, "default"),
|
||||
value: ques.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
options.push({
|
||||
@@ -105,6 +105,7 @@ export function LogicEditor({
|
||||
<SelectItem key="fallback_default_selection" value={"defaultSelection"}>
|
||||
{t("environments.surveys.edit.next_question")}
|
||||
</SelectItem>
|
||||
|
||||
{fallbackOptions.map((option) => (
|
||||
<SelectItem key={`fallback_${option.value}`} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixQuestionForm } from "./matrix-question-form";
|
||||
|
||||
// Mock window.matchMedia - required for useAutoAnimate
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock @formkit/auto-animate - simplify implementation
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [null],
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock findOptionUsedInLogic
|
||||
vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
findOptionUsedInLogic: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
}));
|
||||
|
||||
// Mock tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock QuestionFormInput component
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion }) => (
|
||||
<div data-testid={`question-input-${id}`}>
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
onChange={(e) => {
|
||||
if (updateMatrixLabel) {
|
||||
const type = id.startsWith("row") ? "row" : "column";
|
||||
const index = parseInt(id.split("-")[1]);
|
||||
updateMatrixLabel(index, type, { default: e.target.value });
|
||||
} else if (updateQuestion) {
|
||||
updateQuestion(0, { [id]: { default: e.target.value } });
|
||||
}
|
||||
}}
|
||||
value={value?.default || ""}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock ShuffleOptionSelect component
|
||||
vi.mock("@/modules/ui/components/shuffle-option-select", () => ({
|
||||
ShuffleOptionSelect: vi.fn(() => <div data-testid="shuffle-option-select" />),
|
||||
}));
|
||||
|
||||
// Mock TooltipRenderer component
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: vi.fn(({ children }) => (
|
||||
<div data-testid="tooltip-renderer">
|
||||
{children}
|
||||
<button>Delete</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock("../lib/validation", () => ({
|
||||
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock survey languages
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock matrix question
|
||||
const mockMatrixQuestion: TSurveyMatrixQuestion = {
|
||||
id: "matrix-1",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: createI18nString("Matrix Question", ["en"]),
|
||||
subheader: createI18nString("Please rate the following", ["en"]),
|
||||
required: false,
|
||||
logic: [],
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", ["en"]),
|
||||
createI18nString("Column 2", ["en"]),
|
||||
createI18nString("Column 3", ["en"]),
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
// Mock survey
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
questions: [mockMatrixQuestion],
|
||||
languages: mockSurveyLanguages,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockUpdateQuestion = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
localSurvey: mockSurvey,
|
||||
question: mockMatrixQuestion,
|
||||
questionIdx: 0,
|
||||
updateQuestion: mockUpdateQuestion,
|
||||
lastQuestion: false,
|
||||
selectedLanguageCode: "en",
|
||||
setSelectedLanguageCode: vi.fn(),
|
||||
isInvalid: false,
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
describe("MatrixQuestionForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the matrix question form with rows and columns", () => {
|
||||
render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("question-input-headline")).toBeInTheDocument();
|
||||
|
||||
// Check for rows and columns
|
||||
expect(screen.getByTestId("question-input-row-0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-input-row-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-input-column-0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-input-column-1")).toBeInTheDocument();
|
||||
|
||||
// Check for shuffle options
|
||||
expect(screen.getByTestId("shuffle-option-select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adds description when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithoutSubheader = {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...mockMatrixQuestion,
|
||||
subheader: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByText } = render(<MatrixQuestionForm {...propsWithoutSubheader} />);
|
||||
|
||||
const addDescriptionButton = getByText("environments.surveys.edit.add_description");
|
||||
await user.click(addDescriptionButton);
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
subheader: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("renders subheader input when subheader is defined", () => {
|
||||
render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adds a new row when 'Add Row' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const addRowButton = getByText("environments.surveys.edit.add_row");
|
||||
await user.click(addRowButton);
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
rows: [
|
||||
mockMatrixQuestion.rows[0],
|
||||
mockMatrixQuestion.rows[1],
|
||||
mockMatrixQuestion.rows[2],
|
||||
{ default: "" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("adds a new column when 'Add Column' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const addColumnButton = getByText("environments.surveys.edit.add_column");
|
||||
await user.click(addColumnButton);
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
columns: [
|
||||
mockMatrixQuestion.columns[0],
|
||||
mockMatrixQuestion.columns[1],
|
||||
mockMatrixQuestion.columns[2],
|
||||
{ default: "" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes a row when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(-1);
|
||||
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
// First delete button is for the first column
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
rows: [mockMatrixQuestion.rows[1], mockMatrixQuestion.rows[2]],
|
||||
});
|
||||
});
|
||||
|
||||
test("doesn't delete a row if it would result in less than 2 rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithMinRows = {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...mockMatrixQuestion,
|
||||
rows: [createI18nString("Row 1", ["en"]), createI18nString("Row 2", ["en"])],
|
||||
},
|
||||
};
|
||||
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithMinRows} />);
|
||||
|
||||
// Try to delete rows until there are only 2 left
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
// Try to delete another row, which should fail
|
||||
vi.mocked(mockUpdateQuestion).mockClear();
|
||||
await user.click(deleteButtons[1].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
// The mockUpdateQuestion should not be called again
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles row input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const rowInput = getByTestId("input-row-0");
|
||||
await user.clear(rowInput);
|
||||
await user.type(rowInput, "New Row Label");
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles column input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const columnInput = getByTestId("input-column-0");
|
||||
await user.clear(columnInput);
|
||||
await user.type(columnInput, "New Column Label");
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles Enter key to add a new row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const rowInput = getByTestId("input-row-0");
|
||||
await user.click(rowInput);
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
rows: [
|
||||
mockMatrixQuestion.rows[0],
|
||||
mockMatrixQuestion.rows[1],
|
||||
mockMatrixQuestion.rows[2],
|
||||
expect.any(Object),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("prevents deletion of a row used in logic", async () => {
|
||||
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("prevents deletion of a column used in logic", async () => {
|
||||
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this column is used in logic
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Column delete buttons are after row delete buttons
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
// Click the first column delete button (index 2)
|
||||
await user.click(deleteButtons[2].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
@@ -10,6 +11,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
@@ -53,6 +55,30 @@ export const MatrixQuestionForm = ({
|
||||
const handleDeleteLabel = (type: "row" | "column", index: number) => {
|
||||
const labels = type === "row" ? question.rows : question.columns;
|
||||
if (labels.length <= 2) return; // Prevent deleting below minimum length
|
||||
|
||||
// check if the label is used in logic
|
||||
if (type === "column") {
|
||||
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, index.toString());
|
||||
if (questionIdx !== -1) {
|
||||
toast.error(
|
||||
t("environments.surveys.edit.column_used_in_logic_error", {
|
||||
questionIndex: questionIdx + 1,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, index.toString(), true);
|
||||
if (questionIdx !== -1) {
|
||||
toast.error(
|
||||
t("environments.surveys.edit.row_used_in_logic_error", {
|
||||
questionIndex: questionIdx + 1,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedLabels = labels.filter((_, idx) => idx !== index);
|
||||
if (type === "row") {
|
||||
updateQuestion(questionIdx, { rows: updatedLabels });
|
||||
@@ -177,7 +203,7 @@ export const MatrixQuestionForm = ({
|
||||
locale={locale}
|
||||
/>
|
||||
{question.rows.length > 2 && (
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -229,7 +255,7 @@ export const MatrixQuestionForm = ({
|
||||
locale={locale}
|
||||
/>
|
||||
{question.columns.length > 2 && (
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -356,6 +356,31 @@ export const getLogicRules = (t: TFnType) => {
|
||||
},
|
||||
],
|
||||
},
|
||||
[`${TSurveyQuestionTypeEnum.Matrix}.row`]: {
|
||||
options: [
|
||||
{
|
||||
label: t("environments.surveys.edit.equals"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: t("environments.surveys.edit.does_not_equal"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: t("environments.surveys.edit.is_empty"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isEmpty,
|
||||
},
|
||||
|
||||
{
|
||||
label: t("environments.surveys.edit.is_not_empty"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isNotEmpty,
|
||||
},
|
||||
{
|
||||
label: t("environments.surveys.edit.is_any_of"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAnyOf,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Address]: {
|
||||
options: [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,28 @@ export const getConditionValueOptions = (
|
||||
const questionOptions = questions
|
||||
.filter((_, idx) => idx <= currQuestionIdx)
|
||||
.map((question) => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
const rows = question.rows.map((row, rowIdx) => ({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: `${getLocalizedValue(question.headline, "default")}: ${getLocalizedValue(row, "default")}`,
|
||||
value: `${question.id}.${rowIdx}`,
|
||||
meta: {
|
||||
type: "question",
|
||||
rowIdx: rowIdx,
|
||||
},
|
||||
}));
|
||||
|
||||
const questionEntry = {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
return [questionEntry, ...rows];
|
||||
}
|
||||
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
@@ -120,7 +142,8 @@ export const getConditionValueOptions = (
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
|
||||
const variableOptions = variables.map((variable) => {
|
||||
return {
|
||||
@@ -191,12 +214,20 @@ export const hasJumpToQuestionAction = (actions: TSurveyLogicActions): boolean =
|
||||
return actions.some((action) => action.objective === "jumpToQuestion");
|
||||
};
|
||||
|
||||
const getQuestionOperatorOptions = (question: TSurveyQuestion, t: TFnType): TComboboxOption[] => {
|
||||
const getQuestionOperatorOptions = (
|
||||
question: TSurveyQuestion,
|
||||
t: TFnType,
|
||||
condition?: TSingleCondition
|
||||
): TComboboxOption[] => {
|
||||
let options: TLogicRuleOption;
|
||||
|
||||
if (question.type === "openText") {
|
||||
const inputType = question.inputType === "number" ? "number" : "text";
|
||||
options = getLogicRules(t).question[`openText.${inputType}`].options;
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.Matrix && condition) {
|
||||
const isMatrixRow =
|
||||
condition.leftOperand.type === "question" && condition.leftOperand?.meta?.row !== undefined;
|
||||
options = getLogicRules(t).question[`matrix${isMatrixRow ? ".row" : ""}`].options;
|
||||
} else {
|
||||
options = getLogicRules(t).question[question.type].options;
|
||||
}
|
||||
@@ -231,11 +262,17 @@ export const getConditionOperatorOptions = (
|
||||
return getLogicRules(t).hiddenField.options;
|
||||
} else if (condition.leftOperand.type === "question") {
|
||||
const questions = localSurvey.questions ?? [];
|
||||
const question = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
const question = questions.find((question) => {
|
||||
let leftOperandQuestionId = condition.leftOperand.value;
|
||||
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
leftOperandQuestionId = condition.leftOperand.value.split(".")[0];
|
||||
}
|
||||
return question.id === leftOperandQuestionId;
|
||||
});
|
||||
|
||||
if (!question) return [];
|
||||
|
||||
return getQuestionOperatorOptions(question, t);
|
||||
return getQuestionOperatorOptions(question, t, condition);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
@@ -262,6 +299,8 @@ export const getMatchValueProps = (
|
||||
"isSubmitted",
|
||||
"isSet",
|
||||
"isNotSet",
|
||||
"isEmpty",
|
||||
"isNotEmpty",
|
||||
].includes(condition.operator)
|
||||
) {
|
||||
return { show: false, options: [] };
|
||||
@@ -572,6 +611,22 @@ export const getMatchValueProps = (
|
||||
inputType: "date",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
const choices = selectedQuestion.columns.map((column, colIdx) => {
|
||||
return {
|
||||
label: getLocalizedValue(column, "default"),
|
||||
value: colIdx.toString(),
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: t("common.choices"), value: "choices", options: choices }],
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
if (selectedVariable?.type === "text") {
|
||||
@@ -1125,7 +1180,8 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
|
||||
export const findOptionUsedInLogic = (
|
||||
survey: TSurvey,
|
||||
questionId: TSurveyQuestionId,
|
||||
optionId: string
|
||||
optionId: string,
|
||||
checkInLeftOperand: boolean = false
|
||||
): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
@@ -1139,7 +1195,15 @@ export const findOptionUsedInLogic = (
|
||||
|
||||
const isUsedInOperand = (condition: TSingleCondition): boolean => {
|
||||
if (condition.leftOperand.type === "question" && condition.leftOperand.value === questionId) {
|
||||
if (condition.rightOperand && condition.rightOperand.type === "static") {
|
||||
if (checkInLeftOperand) {
|
||||
if (condition.leftOperand.meta && Object.entries(condition.leftOperand.meta).length > 0) {
|
||||
const optionIdInMeta = Object.values(condition.leftOperand.meta).some(
|
||||
(metaValue) => metaValue === optionId
|
||||
);
|
||||
return optionIdInMeta;
|
||||
}
|
||||
}
|
||||
if (!checkInLeftOperand && condition.rightOperand && condition.rightOperand.type === "static") {
|
||||
if (Array.isArray(condition.rightOperand.value)) {
|
||||
return condition.rightOperand.value.includes(optionId);
|
||||
} else {
|
||||
|
||||
@@ -188,6 +188,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
|
||||
columns: [{ default: "" }, { default: "" }],
|
||||
buttonLabel: { default: t("templates.next") },
|
||||
backButtonLabel: { default: t("templates.back") },
|
||||
shuffleOption: "none",
|
||||
} as Partial<TSurveyMatrixQuestion>,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,14 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon, ChevronDownIcon, LucideProps, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { ForwardRefExoticComponent, RefAttributes, useEffect, useMemo, useState } from "react";
|
||||
import React, {
|
||||
ForwardRefExoticComponent,
|
||||
Fragment,
|
||||
RefAttributes,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface TComboboxOption {
|
||||
icon?: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
|
||||
@@ -62,7 +69,7 @@ export const InputCombobox = ({
|
||||
allowMultiSelect = false,
|
||||
showCheckIcon = false,
|
||||
comboboxClasses,
|
||||
emptyDropdownText = "environments.surveys.edit.no_option_found",
|
||||
emptyDropdownText,
|
||||
}: InputComboboxProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -175,14 +182,14 @@ export const InputCombobox = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fragment key={idx}>
|
||||
{idx !== 0 && <span>,</span>}
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <option.icon className="h-5 w-5 shrink-0 text-slate-400" />}
|
||||
{option.imgSrc && <Image src={option.imgSrc} alt={option.label} width={24} height={24} />}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -267,7 +274,7 @@ export const InputCombobox = ({
|
||||
)}
|
||||
<CommandList className="m-1">
|
||||
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
|
||||
{t(emptyDropdownText)}
|
||||
{emptyDropdownText ? t(emptyDropdownText) : t("environments.surveys.edit.no_option_found")}
|
||||
</CommandEmpty>
|
||||
{options && options.length > 0 && (
|
||||
<CommandGroup>
|
||||
|
||||
@@ -94,6 +94,7 @@ export default defineConfig({
|
||||
"modules/survey/follow-ups/components/follow-up-item.tsx",
|
||||
"modules/ee/contacts/segments/lib/**/*.ts",
|
||||
"modules/ee/contacts/segments/components/segment-settings.tsx",
|
||||
"modules/survey/editor/lib/utils.tsx",
|
||||
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
|
||||
"modules/ee/sso/components/**/*.tsx",
|
||||
"app/global-error.tsx",
|
||||
@@ -104,9 +105,12 @@ export default defineConfig({
|
||||
"modules/analysis/**/*.ts",
|
||||
"app/lib/survey-builder.ts",
|
||||
"modules/survey/editor/components/end-screen-form.tsx",
|
||||
"modules/survey/editor/components/matrix-question-form.tsx",
|
||||
"lib/utils/billing.ts",
|
||||
"lib/crypto.ts",
|
||||
"lib/surveyLogic/utils.ts",
|
||||
"lib/utils/billing.ts",
|
||||
"survey/editor/lib/utils.tsx",
|
||||
"modules/ui/components/card/index.tsx"
|
||||
],
|
||||
exclude: [
|
||||
|
||||
1424
packages/surveys/src/lib/logic.test.ts
Normal file
1424
packages/surveys/src/lib/logic.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,35 @@ const getLeftOperandValue = (
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentQuestion.type === "matrix" &&
|
||||
typeof responseValue === "object" &&
|
||||
!Array.isArray(responseValue)
|
||||
) {
|
||||
if (leftOperand.meta && leftOperand.meta?.row !== undefined) {
|
||||
const rowIndex = Number(leftOperand.meta.row);
|
||||
|
||||
if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const row = getLocalizedValue(currentQuestion.rows[rowIndex], selectedLanguage);
|
||||
|
||||
const rowValue = responseValue[row];
|
||||
if (rowValue === "") return "";
|
||||
|
||||
if (rowValue) {
|
||||
const columnIndex = currentQuestion.columns.findIndex((column) => {
|
||||
return getLocalizedValue(column, selectedLanguage) === rowValue;
|
||||
});
|
||||
|
||||
if (columnIndex === -1) return undefined;
|
||||
return columnIndex.toString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return data[leftOperand.value];
|
||||
case "variable":
|
||||
const variables = localSurvey.variables || [];
|
||||
@@ -395,6 +424,18 @@ const evaluateSingleCondition = (
|
||||
const values = Object.values(leftValue);
|
||||
return values.length > 0 && !values.includes("");
|
||||
} else return false;
|
||||
case "isSet":
|
||||
case "isNotEmpty":
|
||||
return leftValue !== undefined && leftValue !== null && leftValue !== "";
|
||||
case "isNotSet":
|
||||
return leftValue === undefined || leftValue === null || leftValue === "";
|
||||
case "isEmpty":
|
||||
return leftValue === "";
|
||||
case "isAnyOf":
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string") {
|
||||
return rightValue.includes(leftValue);
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const config = ({ mode }) => {
|
||||
reportsDirectory: "./coverage",
|
||||
include: [
|
||||
"src/lib/api-client.ts",
|
||||
"src/lib/logic.ts",
|
||||
"src/components/buttons/*.tsx"
|
||||
],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
|
||||
@@ -28,6 +28,9 @@ export const ZResponseFilterCondition = z.enum([
|
||||
"booked",
|
||||
"isCompletelySubmitted",
|
||||
"isPartiallySubmitted",
|
||||
"isEmpty",
|
||||
"isNotEmpty",
|
||||
"isAnyOf",
|
||||
]);
|
||||
|
||||
export type TResponseDataValue = z.infer<typeof ZResponseDataValue>;
|
||||
@@ -133,6 +136,19 @@ const ZResponseFilterCriteriaMatrix = z.object({
|
||||
value: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaIsEmpty = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.isEmpty),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaIsNotEmpty = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.isNotEmpty),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaIsAnyOf = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.isAnyOf),
|
||||
value: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaFilledOut = z.object({
|
||||
op: z.literal("filledOut"),
|
||||
});
|
||||
@@ -174,6 +190,9 @@ export const ZResponseFilterCriteria = z.object({
|
||||
ZResponseFilterCriteriaDataNotUploaded,
|
||||
ZResponseFilterCriteriaDataBooked,
|
||||
ZResponseFilterCriteriaMatrix,
|
||||
ZResponseFilterCriteriaIsEmpty,
|
||||
ZResponseFilterCriteriaIsNotEmpty,
|
||||
ZResponseFilterCriteriaIsAnyOf,
|
||||
ZResponseFilterCriteriaFilledOut,
|
||||
])
|
||||
)
|
||||
|
||||
@@ -297,6 +297,9 @@ export const ZSurveyLogicConditionsOperator = z.enum([
|
||||
"isCompletelySubmitted",
|
||||
"isSet",
|
||||
"isNotSet",
|
||||
"isEmpty",
|
||||
"isNotEmpty",
|
||||
"isAnyOf",
|
||||
]);
|
||||
|
||||
const operatorsWithoutRightOperand = [
|
||||
@@ -309,6 +312,8 @@ const operatorsWithoutRightOperand = [
|
||||
ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
|
||||
ZSurveyLogicConditionsOperator.Enum.isSet,
|
||||
ZSurveyLogicConditionsOperator.Enum.isNotSet,
|
||||
ZSurveyLogicConditionsOperator.Enum.isEmpty,
|
||||
ZSurveyLogicConditionsOperator.Enum.isNotEmpty,
|
||||
] as const;
|
||||
|
||||
export const ZDynamicLogicField = z.enum(["question", "variable", "hiddenField"]);
|
||||
@@ -324,6 +329,7 @@ export const ZActionNumberVariableCalculateOperator = z.enum(
|
||||
const ZDynamicQuestion = z.object({
|
||||
type: z.literal("question"),
|
||||
value: z.string().min(1, "Conditional Logic: Question id cannot be empty"),
|
||||
meta: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
const ZDynamicVariable = z.object({
|
||||
@@ -1464,7 +1470,18 @@ const isInvalidOperatorsForQuestionType = (
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
if (!["isPartiallySubmitted", "isCompletelySubmitted", "isSkipped"].includes(operator)) {
|
||||
if (
|
||||
![
|
||||
"isPartiallySubmitted",
|
||||
"isCompletelySubmitted",
|
||||
"isSkipped",
|
||||
"isEmpty",
|
||||
"isNotEmpty",
|
||||
"isAnyOf",
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
].includes(operator)
|
||||
) {
|
||||
isInvalidOperator = true;
|
||||
}
|
||||
break;
|
||||
@@ -1598,6 +1615,8 @@ const validateConditions = (
|
||||
"isBooked",
|
||||
"isPartiallySubmitted",
|
||||
"isCompletelySubmitted",
|
||||
"isEmpty",
|
||||
"isNotEmpty",
|
||||
].includes(operator)
|
||||
) {
|
||||
if (rightOperand !== undefined) {
|
||||
@@ -1909,6 +1928,49 @@ const validateConditions = (
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
const row = leftOperand.meta?.row;
|
||||
if (row === undefined) {
|
||||
if (rightOperand?.value !== undefined) {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Right operand is not allowed in matrix question in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
|
||||
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
|
||||
});
|
||||
}
|
||||
if (!["isPartiallySubmitted", "isCompletelySubmitted"].includes(operator)) {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Operator "${operator}" is not allowed in matrix question in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
|
||||
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (rightOperand === undefined) {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Right operand is required in matrix question in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
|
||||
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
|
||||
});
|
||||
}
|
||||
if (rightOperand) {
|
||||
if (rightOperand.type !== "static") {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Right operand should be a static value in matrix question in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
|
||||
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
|
||||
});
|
||||
}
|
||||
const rowIndex = Number(row);
|
||||
if (rowIndex < 0 || rowIndex >= question.rows.length) {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Invalid row index in matrix question in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
|
||||
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (leftOperand.type === "variable") {
|
||||
const variableId = leftOperand.value;
|
||||
|
||||
Reference in New Issue
Block a user