@@ -133,15 +115,12 @@ export function LogicEditor({
- {t("environments.surveys.edit.next_question")}
+ {t("environments.surveys.edit.next_block")}
{fallbackOptions.map((option) => (
-
- {option.icon}
- {option.label}
-
+ {option.label}
))}
diff --git a/apps/web/modules/survey/editor/components/open-question-form.tsx b/apps/web/modules/survey/editor/components/open-question-form.tsx
index 9512e70cbd..a467681acf 100644
--- a/apps/web/modules/survey/editor/components/open-question-form.tsx
+++ b/apps/web/modules/survey/editor/components/open-question-form.tsx
@@ -181,7 +181,7 @@ export const OpenQuestionForm = ({
},
});
}}
- htmlId="charLimit"
+ htmlId={`charLimit-${question.id}`}
description={t("environments.surveys.edit.character_limit_toggle_description")}
childBorder
title={t("environments.surveys.edit.character_limit_toggle_title")}
@@ -238,7 +238,7 @@ export const OpenQuestionForm = ({
longAnswer: checked,
});
}}
- htmlId="longAnswer"
+ htmlId={`longAnswer-${question.id}`}
title={t("environments.surveys.edit.long_answer")}
description={t("environments.surveys.edit.long_answer_toggle_description")}
disabled={question.inputType !== "text"}
diff --git a/apps/web/modules/survey/editor/components/questions-view.tsx b/apps/web/modules/survey/editor/components/questions-view.tsx
index b9a2ec9243..5b392ea56c 100644
--- a/apps/web/modules/survey/editor/components/questions-view.tsx
+++ b/apps/web/modules/survey/editor/components/questions-view.tsx
@@ -600,7 +600,7 @@ export const QuestionsView = ({
// If source block is now empty, delete it
if (sourceBlock.elements.length === 0) {
- const blockIdx = updatedSurvey.blocks.findIndex((b) => b.id === sourceBlock!.id);
+ const blockIdx = updatedSurvey.blocks.findIndex((b) => b.id === sourceBlock.id);
if (blockIdx !== -1) {
updatedSurvey.blocks.splice(blockIdx, 1);
}
@@ -778,7 +778,6 @@ export const QuestionsView = ({
blocks.splice(destBlockIndex, 0, movedBlock);
setLocalSurvey({ ...localSurvey, blocks });
- return;
}
};
diff --git a/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts b/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts
index c86d235578..e86cc090c1 100644
--- a/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts
+++ b/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts
@@ -1,4 +1,5 @@
import { createId } from "@paralleldrive/cuid2";
+import { TFunction } from "i18next";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuotaLogic } from "@formbricks/types/quota";
import {
@@ -87,8 +88,12 @@ vi.mock("@/modules/survey/editor/lib/utils", () => ({
{ value: "notEquals", label: "not equals" },
{ value: "isEmpty", label: "is empty" },
]),
- getDefaultOperatorForQuestion: vi.fn().mockReturnValue("equals"),
getDefaultOperatorForElement: vi.fn().mockReturnValue("equals"),
+ getElementOperatorOptions: vi.fn().mockReturnValue([
+ { value: "equals", label: "equals" },
+ { value: "notEquals", label: "not equals" },
+ { value: "isEmpty", label: "is empty" },
+ ]),
}));
vi.mock("@paralleldrive/cuid2", () => ({
@@ -169,7 +174,7 @@ describe("shared-conditions-factory", () => {
const defaultParams: SharedConditionsFactoryParams = {
survey: mockSurvey,
- t: mockT,
+ t: mockT as unknown as TFunction,
getDefaultOperator: mockGetDefaultOperator,
};
@@ -244,15 +249,15 @@ describe("shared-conditions-factory", () => {
result.config.getLeftOperandOptions();
const { getConditionValueOptions } = await import("@/modules/survey/editor/lib/utils");
- expect(getConditionValueOptions).toHaveBeenCalledWith(mockSurvey, mockT);
+ expect(getConditionValueOptions).toHaveBeenCalledWith(mockSurvey, mockT, undefined);
});
test("should call getConditionValueOptions with questionIdx", async () => {
- const paramsWithQuestionIdx = {
+ const paramsWithBlockIdx = {
...defaultParams,
blockIdx: 0,
};
- const result = createSharedConditionsFactory(paramsWithQuestionIdx, defaultCallbacks);
+ const result = createSharedConditionsFactory(paramsWithBlockIdx, defaultCallbacks);
result.config.getLeftOperandOptions();
@@ -271,15 +276,15 @@ describe("shared-conditions-factory", () => {
result.config.getValueProps(mockCondition);
const { getMatchValueProps } = await import("@/modules/survey/editor/lib/utils");
- expect(getMatchValueProps).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT);
+ expect(getMatchValueProps).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT, undefined);
});
test("should call getMatchValueProps with questionIdx", async () => {
- const paramsWithQuestionIdx = {
+ const paramsWithBlockIdx = {
...defaultParams,
blockIdx: 0,
};
- const result = createSharedConditionsFactory(paramsWithQuestionIdx, defaultCallbacks);
+ const result = createSharedConditionsFactory(paramsWithBlockIdx, defaultCallbacks);
const mockCondition: TSingleCondition = {
id: "condition1",
leftOperand: { value: "question1", type: "element" },
@@ -301,6 +306,30 @@ describe("shared-conditions-factory", () => {
expect(mockGetDefaultOperator).toHaveBeenCalled();
});
+ test("should get operator options for condition", async () => {
+ const { getConditionOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
+ const mockGetConditionOperatorOptions = vi.mocked(getConditionOperatorOptions);
+ mockGetConditionOperatorOptions.mockReturnValue([
+ { value: "equals", label: "equals" },
+ { value: "doesNotEqual", label: "does not equal" },
+ ]);
+
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const mockCondition: TSingleCondition = {
+ id: "condition1",
+ leftOperand: { value: "question1", type: "element" },
+ operator: "equals",
+ };
+
+ const operators = result.config.getOperatorOptions(mockCondition);
+
+ expect(mockGetConditionOperatorOptions).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT);
+ expect(operators).toEqual([
+ { value: "equals", label: "equals" },
+ { value: "doesNotEqual", label: "does not equal" },
+ ]);
+ });
+
test("should format left operand value", async () => {
const { getFormatLeftOperandValue } = await import("@/modules/survey/editor/lib/utils");
const mockGetFormatLeftOperandValue = vi.mocked(getFormatLeftOperandValue);
@@ -361,6 +390,139 @@ describe("shared-conditions-factory", () => {
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
});
+ test("onUpdateCondition should correct invalid operator for element type", async () => {
+ const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
+ const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
+
+ // Mock to return limited operators (e.g., only isEmpty and isNotEmpty)
+ mockGetElementOperatorOptions.mockReturnValue([
+ { value: "isEmpty", label: "is empty" },
+ { value: "isNotEmpty", label: "is not empty" },
+ ]);
+
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const resourceId = "condition1";
+ const updates = {
+ leftOperand: {
+ value: "question1",
+ type: "element" as const,
+ },
+ operator: "equals" as TSurveyLogicConditionsOperator, // Invalid operator for this element
+ };
+
+ result.callbacks.onUpdateCondition(resourceId, updates);
+
+ expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+
+ // Get the updater function that was called
+ const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
+ const mockConditions: TConditionGroup = {
+ id: "root",
+ connector: "and",
+ conditions: [
+ {
+ id: "condition1",
+ leftOperand: { value: "oldQuestion", type: "element" },
+ operator: "equals",
+ },
+ ],
+ };
+
+ updater(structuredClone(mockConditions));
+
+ // Verify the operator was validated
+ expect(mockGetElementOperatorOptions).toHaveBeenCalled();
+ });
+
+ test("onUpdateCondition should handle update with valid operator", async () => {
+ const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
+ const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
+
+ // Mock to return operators that include the one being set
+ mockGetElementOperatorOptions.mockReturnValue([
+ { value: "equals", label: "equals" },
+ { value: "doesNotEqual", label: "does not equal" },
+ ]);
+
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const resourceId = "condition1";
+ const updates = {
+ leftOperand: {
+ value: "question1",
+ type: "element" as const,
+ },
+ operator: "equals" as TSurveyLogicConditionsOperator, // Valid operator
+ };
+
+ result.callbacks.onUpdateCondition(resourceId, updates);
+
+ expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ test("onUpdateCondition should handle update without leftOperand", () => {
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const resourceId = "condition1";
+ const updates = {
+ operator: "equals" as TSurveyLogicConditionsOperator,
+ };
+
+ result.callbacks.onUpdateCondition(resourceId, updates);
+
+ expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ test("onUpdateCondition should handle update without operator", () => {
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const resourceId = "condition1";
+ const updates = {
+ leftOperand: {
+ value: "question1",
+ type: "element" as const,
+ },
+ };
+
+ result.callbacks.onUpdateCondition(resourceId, updates);
+
+ expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ test("onUpdateCondition should handle non-question leftOperand type", () => {
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const resourceId = "condition1";
+ const updates = {
+ leftOperand: {
+ value: "variable1",
+ type: "variable" as const,
+ },
+ operator: "equals" as TSurveyLogicConditionsOperator,
+ };
+
+ result.callbacks.onUpdateCondition(resourceId, updates);
+
+ expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ test("onUpdateCondition should handle element not found", async () => {
+ const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
+ const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
+
+ const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
+ const resourceId = "condition1";
+ const updates = {
+ leftOperand: {
+ value: "non-existent-question",
+ type: "element" as const,
+ },
+ operator: "equals" as TSurveyLogicConditionsOperator,
+ };
+
+ result.callbacks.onUpdateCondition(resourceId, updates);
+
+ expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+ // Should not call getElementOperatorOptions if element not found
+ expect(mockGetElementOperatorOptions).not.toHaveBeenCalled();
+ });
+
test("onToggleGroupConnector should toggle group connector", () => {
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const groupId = "group1";
@@ -368,6 +530,21 @@ describe("shared-conditions-factory", () => {
result.callbacks.onToggleGroupConnector(groupId);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+
+ // Execute the updater function to ensure it runs properly
+ const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
+ const mockConditions: TConditionGroup = {
+ id: "root",
+ connector: "and",
+ conditions: [
+ {
+ id: "group1",
+ connector: "and",
+ conditions: [],
+ },
+ ],
+ };
+ updater(mockConditions);
});
test("onCreateGroup should create group when includeCreateGroup is true", () => {
@@ -381,6 +558,21 @@ describe("shared-conditions-factory", () => {
result.callbacks.onCreateGroup!(resourceId);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
+
+ // Execute the updater function to ensure it runs properly
+ const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
+ const mockConditions: TConditionGroup = {
+ id: "root",
+ connector: "and",
+ conditions: [
+ {
+ id: "condition1",
+ leftOperand: { value: "question1", type: "question" },
+ operator: "equals",
+ },
+ ],
+ };
+ updater(mockConditions);
});
});
diff --git a/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts b/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts
index 07c331ba9c..254597d288 100644
--- a/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts
+++ b/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts
@@ -91,15 +91,9 @@ export function createSharedConditionsFactory(
};
const config: TConditionsEditorConfig
= {
- getLeftOperandOptions: () =>
- blockIdx !== undefined
- ? getConditionValueOptions(survey, t, blockIdx)
- : getConditionValueOptions(survey, t),
+ getLeftOperandOptions: () => getConditionValueOptions(survey, t, blockIdx),
getOperatorOptions: (condition) => getConditionOperatorOptions(condition, survey, t),
- getValueProps: (condition) =>
- blockIdx !== undefined
- ? getMatchValueProps(condition, survey, t, blockIdx)
- : getMatchValueProps(condition, survey, t),
+ getValueProps: (condition) => getMatchValueProps(condition, survey, t, blockIdx),
getDefaultOperator,
formatLeftOperandValue: (condition) => getFormatLeftOperandValue(condition, survey),
};
diff --git a/apps/web/modules/survey/editor/lib/utils.tsx b/apps/web/modules/survey/editor/lib/utils.tsx
index f0d197265e..bac1da4d2b 100644
--- a/apps/web/modules/survey/editor/lib/utils.tsx
+++ b/apps/web/modules/survey/editor/lib/utils.tsx
@@ -26,7 +26,7 @@ import { isConditionGroup } from "@/lib/surveyLogic/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
-import { getQuestionTypes } from "@/modules/survey/lib/questions";
+import { getQuestionTypes, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine";
@@ -106,6 +106,23 @@ const getQuestionIconMapping = (t: TFunction) =>
{}
);
+const getElementHeadline = (
+ localSurvey: TSurvey,
+ element: TSurveyElement,
+ languageCode: string,
+ t: TFunction
+): string => {
+ const headlineData = recallToHeadline(element.headline, localSurvey, false, languageCode);
+ const headlineText = headlineData[languageCode];
+ if (headlineText) {
+ const textContent = getTextContent(headlineText);
+ if (textContent.length > 0) {
+ return textContent;
+ }
+ }
+ return getTSurveyQuestionTypeEnumName(element.type, t) ?? "";
+};
+
export const getConditionValueOptions = (
localSurvey: TSurvey,
t: TFunction,
@@ -117,20 +134,18 @@ export const getConditionValueOptions = (
// If blockIdx is provided, get elements from current block and all previous blocks
// Otherwise, get all elements from all blocks
const allElements =
- blockIdx !== undefined
- ? localSurvey.blocks
- .slice(0, blockIdx + 1) // Include blocks from 0 to blockIdx (inclusive)
- .flatMap((block) => block.elements)
- : getElementsFromBlocks(localSurvey.blocks);
+ blockIdx === undefined
+ ? getElementsFromBlocks(localSurvey.blocks)
+ : localSurvey.blocks.slice(0, blockIdx + 1).flatMap((block) => block.elements);
const groupedOptions: TComboboxGroupedOption[] = [];
const elementOptions: TComboboxOption[] = [];
allElements.forEach((element) => {
if (element.type === TSurveyElementTypeEnum.Matrix) {
+ const elementHeadline = getElementHeadline(localSurvey, element, "default", t);
+
// Rows submenu
- const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
- const elementHeadline = getTextContent(processedHeadline.default ?? "");
const rows = element.rows.map((row, rowIdx) => {
const processedLabel = recallToHeadline(row.label, localSurvey, false, "default");
return {
@@ -169,9 +184,7 @@ export const getConditionValueOptions = (
} else {
elementOptions.push({
icon: getQuestionIconMapping(t)[element.type],
- label: getTextContent(
- recallToHeadline(element.headline, localSurvey, false, "default").default ?? ""
- ),
+ label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "element",
@@ -358,11 +371,11 @@ export const getMatchValueProps = (
// If blockIdx is provided, get elements from current block and all previous blocks
// Otherwise, get all elements from all blocks
let elements =
- blockIdx !== undefined
- ? localSurvey.blocks
+ blockIdx === undefined
+ ? getElementsFromBlocks(localSurvey.blocks)
+ : localSurvey.blocks
.slice(0, blockIdx + 1) // Include blocks from 0 to blockIdx (inclusive)
- .flatMap((block) => block.elements)
- : getElementsFromBlocks(localSurvey.blocks);
+ .flatMap((block) => block.elements);
let variables = localSurvey.variables ?? [];
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
@@ -413,7 +426,7 @@ export const getMatchValueProps = (
const variableOptions = variables
.filter((variable) =>
- selectedElement.inputType !== "number" ? variable.type === "text" : variable.type === "number"
+ selectedElement.inputType === "number" ? variable.type === "number" : variable.type === "text"
)
.map((variable) => {
return {
@@ -715,10 +728,9 @@ export const getMatchValueProps = (
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
const elementOptions = allowedElements.map((element) => {
- const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[element.type],
- label: getTextContent(processedHeadline.default ?? ""),
+ label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "element",
@@ -790,10 +802,9 @@ export const getMatchValueProps = (
);
const elementOptions = allowedElements.map((element) => {
- const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[element.type],
- label: getTextContent(processedHeadline.default ?? ""),
+ label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "element",
@@ -871,10 +882,9 @@ export const getMatchValueProps = (
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
const elementOptions = allowedElements.map((element) => {
- const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[element.type],
- label: getTextContent(processedHeadline.default ?? ""),
+ label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "element",
@@ -967,11 +977,10 @@ export const getActionTargetOptions = (
// Return element IDs for requireAnswer
return nonRequiredElements.map((element) => {
- const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[element.type],
- label: getTextContent(processedHeadline.default ?? ""),
- value: element.id, // Element ID
+ label: getElementHeadline(localSurvey, element, "default", t),
+ value: element.id,
};
});
}
@@ -1114,10 +1123,9 @@ export const getActionValueOptions = (
);
const elementOptions = allowedElements.map((element) => {
- const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[element.type],
- label: getTextContent(processedHeadline.default ?? ""),
+ label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "element",