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:
Piyush Gupta
2025-04-29 11:17:05 +05:30
committed by GitHub
parent 7c8f3e826f
commit 0f1bdce002
23 changed files with 4510 additions and 141 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 || [];

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "比例",

View File

@@ -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);
}}

View File

@@ -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">

View File

@@ -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();
});
});

View File

@@ -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"

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>,
},
{

View File

@@ -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>

View File

@@ -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: [

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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/**"],

View File

@@ -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,
])
)

View File

@@ -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;