From d56f05fb19c6d75f3e9267295924b1d2f8faf02c Mon Sep 17 00:00:00 2001 From: Piyush Gupta Date: Wed, 28 Aug 2024 16:35:05 +0530 Subject: [PATCH] fix: question, hiddenfield, variable delete error if used in logic --- .../components/AdvancedLogicEditorActions.tsx | 2 +- .../edit/components/HiddenFieldsCard.tsx | 30 ++-- .../components/MultipleChoiceQuestionForm.tsx | 38 ++--- .../edit/components/QuestionsView.tsx | 97 ++++++++++-- .../components/SurveyVariablesCardItem.tsx | 12 +- .../surveys/[surveyId]/edit/lib/util.tsx | 147 +++++++++++++++++- apps/web/app/lib/formbricks.ts | 47 ------ .../surveys/src/components/general/Survey.tsx | 75 ++++++--- packages/surveys/src/lib/logicEvaluator.ts | 30 ++-- 9 files changed, 347 insertions(+), 131 deletions(-) diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditorActions.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditorActions.tsx index f99bc112a1..ce2e6d6088 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditorActions.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditorActions.tsx @@ -107,7 +107,7 @@ export function AdvancedLogicEditorActions({ options={ action.objective === "calculate" ? getActionVariableOptions(localSurvey) - : getActionTargetOptions(localSurvey, questionIdx) + : getActionTargetOptions(action, localSurvey, questionIdx) } selected={action.objective === "calculate" ? action.variableId : action.target} onChangeValue={(val: string) => { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx index b21fafaba9..ad892e0219 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx @@ -1,5 +1,6 @@ "use client"; +import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; import { toast } from "react-hot-toast"; @@ -64,6 +65,25 @@ export const HiddenFieldsCard = ({ }); }; + const handleDeleteHiddenField = (fieldId: string) => { + const quesIdx = findHiddenFieldUsedInLogic(localSurvey, fieldId); + + if (quesIdx !== -1) { + toast.error( + `${fieldId} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.` + ); + return; + } + + updateSurvey( + { + enabled: true, + fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId), + }, + fieldId + ); + }; + return (
{ - updateSurvey( - { - enabled: true, - fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId), - }, - fieldId - ); - }} + onDelete={(fieldId) => handleDeleteHiddenField(fieldId)} tagId={fieldId} tagName={fieldId} /> diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx index 4a0f017e79..3a68c47b8e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx @@ -1,10 +1,12 @@ "use client"; +import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util"; import { DndContext } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { createId } from "@paralleldrive/cuid2"; import { PlusIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { @@ -68,8 +70,6 @@ export const MultipleChoiceQuestionForm = ({ }; const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => { - // const newLabel = updatedAttributes.label.en; - // const oldLabel = question.choices[choiceIdx].label; let newChoices: any[] = []; if (question.choices) { newChoices = question.choices.map((choice, idx) => { @@ -78,21 +78,8 @@ export const MultipleChoiceQuestionForm = ({ }); } - // let newLogic: any[] = []; - // question.logic?.forEach((logic) => { - // let newL: string | string[] | undefined = logic.value; - // if (Array.isArray(logic.value)) { - // newL = logic.value.map((value) => - // value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : value - // ); - // } else { - // newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value; - // } - // newLogic.push({ ...logic, value: newL }); - // }); updateQuestion(questionIdx, { choices: newChoices, - // logic: newLogic }); }; @@ -135,25 +122,24 @@ export const MultipleChoiceQuestionForm = ({ }; const deleteChoice = (choiceIdx: number) => { + const choiceToDelete = question.choices[choiceIdx].id; + + const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete); + if (questionIdx !== -1) { + toast.error( + `This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.` + ); + return; + } + const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx); const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode]; if (isInvalidValue === choiceValue) { setisInvalidValue(null); } - // let newLogic: any[] = []; - // question.logic?.forEach((logic) => { - // let newL: string | string[] | undefined = logic.value; - // if (Array.isArray(logic.value)) { - // newL = logic.value.filter((value) => value !== choiceValue); - // } else { - // newL = logic.value !== choiceValue ? logic.value : undefined; - // } - // newLogic.push({ ...logic, value: newL }); - // }); updateQuestion(questionIdx, { choices: newChoices, - // logic: newLogic }); }; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index e068f41dad..293cfc1bfd 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -2,6 +2,7 @@ import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton"; import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard"; +import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util"; import { DndContext, DragEndEvent, @@ -17,11 +18,18 @@ import toast from "react-hot-toast"; import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card"; import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; +import { isConditionsGroup } from "@formbricks/lib/survey/logic/utils"; import { getDefaultEndingCard } from "@formbricks/lib/templates"; import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TOrganizationBillingPlan } from "@formbricks/types/organizations"; import { TProduct } from "@formbricks/types/product"; +import { + TAction, + TConditionGroup, + TSingleCondition, + TSurveyAdvancedLogic, +} from "@formbricks/types/surveys/logic"; import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation"; import { @@ -76,22 +84,75 @@ export const QuestionsView = ({ const surveyLanguages = localSurvey.languages; const [backButtonLabel, setbackButtonLabel] = useState(null); + const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => { - survey.questions.forEach((question) => { - if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) { - question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll( - `recall:${compareId}`, - `recall:${updatedId}` - ); + const updateConditions = (conditions: TConditionGroup): TConditionGroup => { + return { + ...conditions, + conditions: conditions.conditions.map((condition) => { + if (isConditionsGroup(condition)) { + return updateConditions(condition); + } else { + return updateSingleCondition(condition); + } + }), + }; + }; + + const updateSingleCondition = (condition: TSingleCondition): TSingleCondition => { + let updatedCondition = { ...condition }; + + if (condition.leftOperand.id === compareId) { + updatedCondition.leftOperand = { ...condition.leftOperand, id: updatedId }; } - if (!question.logic) return; - question.logic.forEach((rule) => { - if (rule.destination === compareId) { - rule.destination = updatedId; + + if (condition.rightOperand?.type === "question" && condition.rightOperand.value === compareId) { + updatedCondition.rightOperand = { ...condition.rightOperand, value: updatedId }; + } + + return updatedCondition; + }; + + const updateActions = (actions: TAction[]): TAction[] => { + return actions.map((action) => { + let updatedAction = { ...action }; + + if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) { + updatedAction.target = updatedId; } + + if (updatedAction.objective === "requireAnswer" && updatedAction.target === compareId) { + updatedAction.target = updatedId; + } + + return updatedAction; }); - }); - return survey; + }; + + return { + ...survey, + questions: survey.questions.map((question) => { + let updatedQuestion = { ...question }; + + if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) { + question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll( + `recall:${compareId}`, + `recall:${updatedId}` + ); + } + + // Update advanced logic + if (question.logic) { + updatedQuestion.logic = question.logic.map((logicRule: TSurveyAdvancedLogic) => ({ + ...logicRule, + conditions: updateConditions(logicRule.conditions), + actions: updateActions(logicRule.actions), + })); + } + + return updatedQuestion; + }), + }; }; useEffect(() => { @@ -210,6 +271,16 @@ export const QuestionsView = ({ const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id; let updatedSurvey: TSurvey = { ...localSurvey }; + // checking if this question is used in logic of any other question + const quesIdx = findQuestionUsedInLogic(localSurvey, questionId); + + if (quesIdx !== -1) { + toast.error( + `This question is used in logic of question ${quesIdx + 1}. ${localSurvey.questions[quesIdx].headline["default"]}` + ); + return; + } + // check if we are recalling from this question for every language updatedSurvey.questions.forEach((question) => { for (const [languageCode, headline] of Object.entries(question.headline)) { @@ -222,7 +293,7 @@ export const QuestionsView = ({ } }); updatedSurvey.questions.splice(questionIdx, 1); - updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, ""); + const firstEndingCard = localSurvey.endings[0]; setLocalSurvey(updatedSurvey); delete internalQuestionIdMap[questionId]; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx index 63820f46fa..016dc6adaf 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx @@ -1,9 +1,11 @@ "use client"; +import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util"; import { createId } from "@paralleldrive/cuid2"; import { TrashIcon } from "lucide-react"; import React, { useCallback, useEffect } from "react"; import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; import { extractRecallInfo } from "@formbricks/lib/utils/recall"; import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types"; import { Button } from "@formbricks/ui/Button"; @@ -75,8 +77,16 @@ export const SurveyVariablesCardItem = ({ const onVaribleDelete = (variable: TSurveyVariable) => { const questions = [...localSurvey.questions]; - // find if this variable is used in any question's recall and remove it for every language + const quesIdx = findVariableUsedInLogic(localSurvey, variable.id); + if (quesIdx !== -1) { + toast.error( + `${variable.name} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.` + ); + return; + } + + // find if this variable is used in any question's recall and remove it for every language questions.forEach((question) => { for (const [languageCode, headline] of Object.entries(question.headline)) { if (headline.includes(`recall:${variable.id}`)) { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util.tsx index 3fde06e92b..12bad7286e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util.tsx @@ -18,10 +18,16 @@ import { StarIcon, } from "lucide-react"; import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { isConditionsGroup } from "@formbricks/lib/survey/logic/utils"; import { + TAction, TActionObjective, TActionVariableCalculateOperator, + TConditionGroup, + TLeftOperand, + TRightOperand, TSingleCondition, + TSurveyAdvancedLogic, } from "@formbricks/types/surveys/logic"; import { TSurvey, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types"; import { ComboboxGroupedOption, ComboboxOption } from "@formbricks/ui/InputCombobox"; @@ -336,7 +342,11 @@ export const getMatchValueProps = ( return { show: true, options: [] }; }; -export const getActionTargetOptions = (localSurvey: TSurvey, currQuestionIdx: number): ComboboxOption[] => { +export const getActionTargetOptions = ( + action: TAction, + localSurvey: TSurvey, + currQuestionIdx: number +): ComboboxOption[] => { const questionOptions = localSurvey.questions .filter((_, idx) => idx !== currQuestionIdx) .map((question) => { @@ -347,11 +357,13 @@ export const getActionTargetOptions = (localSurvey: TSurvey, currQuestionIdx: nu }; }); + if (action.objective === "requireAnswer") return questionOptions; + const endingCardOptions = localSurvey.endings.map((ending) => { return { label: ending.type === "endScreen" - ? `🙏${getLocalizedValue(ending.headline, "default")}` + ? `🙏 ${getLocalizedValue(ending.headline, "default")}` : `🙏 ${ending.label || "Redirect Thank you card"}`, value: ending.id, }; @@ -492,3 +504,134 @@ export const getActionValueOptions = ( return groupedOptions; }; + +export const findQuestionUsedInLogic = (survey: TSurvey, questionId: string): number => { + const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => { + if (isConditionsGroup(condition)) { + // It's a TConditionGroup + return condition.conditions.some(isUsedInCondition); + } else { + // It's a TSingleCondition + return ( + (condition.rightOperand && isUsedInRightOperand(condition.rightOperand, questionId)) || + isUsedInLeftOperand(condition.leftOperand, questionId) + ); + } + }; + + const isUsedInLeftOperand = (leftOperand: TLeftOperand, id: string): boolean => { + return leftOperand.type === "question" && leftOperand.id === id; + }; + + const isUsedInRightOperand = (rightOperand: TRightOperand, id: string): boolean => { + return rightOperand.type === "question" && rightOperand.value === id; + }; + + const isUsedInAction = (action: TAction): boolean => { + return ( + (action.objective === "jumpToQuestion" && action.target === questionId) || + (action.objective === "requireAnswer" && action.target === questionId) + ); + }; + + const isUsedInLogicRule = (logicRule: TSurveyAdvancedLogic): boolean => { + return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction); + }; + + return survey.questions + .filter((question) => question.id !== questionId) + .findIndex((question) => question.logic && question.logic.some(isUsedInLogicRule)); +}; + +export const findOptionUsedInLogic = (survey: TSurvey, questionId: string, optionId: string): number => { + const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => { + if (isConditionsGroup(condition)) { + // It's a TConditionGroup + return condition.conditions.some(isUsedInCondition); + } else { + // It's a TSingleCondition + return isUsedInOperand(condition); + } + }; + + const isUsedInOperand = (condition: TSingleCondition): boolean => { + if (condition.leftOperand.type === "question" && condition.leftOperand.id === questionId) { + if (condition.rightOperand && condition.rightOperand.type === "static") { + if (Array.isArray(condition.rightOperand.value)) { + return condition.rightOperand.value.includes(optionId); + } else { + return condition.rightOperand.value === optionId; + } + } + } + return false; + }; + + const isUsedInLogicRule = (logicRule: TSurveyAdvancedLogic): boolean => { + return isUsedInCondition(logicRule.conditions); + }; + + return survey.questions.findIndex((question) => question.logic && question.logic.some(isUsedInLogicRule)); +}; + +export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => { + const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => { + if (isConditionsGroup(condition)) { + // It's a TConditionGroup + return condition.conditions.some(isUsedInCondition); + } else { + // It's a TSingleCondition + return ( + (condition.rightOperand && isUsedInRightOperand(condition.rightOperand)) || + isUsedInLeftOperand(condition.leftOperand) + ); + } + }; + + const isUsedInLeftOperand = (leftOperand: TLeftOperand): boolean => { + return leftOperand.type === "variable" && leftOperand.id === variableId; + }; + + const isUsedInRightOperand = (rightOperand: TRightOperand): boolean => { + return rightOperand.type === "variable" && rightOperand.value === variableId; + }; + + const isUsedInAction = (action: TAction): boolean => { + return action.objective === "calculate" && action.variableId === variableId; + }; + + const isUsedInLogicRule = (logicRule: TSurveyAdvancedLogic): boolean => { + return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction); + }; + + return survey.questions.findIndex((question) => question.logic && question.logic.some(isUsedInLogicRule)); +}; + +export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => { + const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => { + if (isConditionsGroup(condition)) { + // It's a TConditionGroup + return condition.conditions.some(isUsedInCondition); + } else { + // It's a TSingleCondition + return ( + (condition.rightOperand && isUsedInRightOperand(condition.rightOperand)) || + isUsedInLeftOperand(condition.leftOperand) + ); + } + }; + + const isUsedInLeftOperand = (leftOperand: TLeftOperand): boolean => { + return leftOperand.type === "hiddenField" && leftOperand.id === hiddenFieldId; + }; + + const isUsedInRightOperand = (rightOperand: TRightOperand): boolean => { + return rightOperand.type === "hiddenField" && rightOperand.value === hiddenFieldId; + }; + + const isUsedInLogicRule = (logicRule: TSurveyAdvancedLogic): boolean => { + return isUsedInCondition(logicRule.conditions); + }; + + return survey.questions.findIndex((question) => question.logic && question.logic.some(isUsedInLogicRule)); +}; diff --git a/apps/web/app/lib/formbricks.ts b/apps/web/app/lib/formbricks.ts index 23bc5f902d..cc496be1d7 100644 --- a/apps/web/app/lib/formbricks.ts +++ b/apps/web/app/lib/formbricks.ts @@ -5,53 +5,6 @@ import { env } from "@formbricks/lib/env"; export const formbricksEnabled = typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID; -// !@gupta-piyush19, @mattinannt : can we remove this code? -// const ttc = { onboarding: 0 }; - -// const getFormbricksApi = () => { -// const environmentId = env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID; -// const apiHost = env.NEXT_PUBLIC_FORMBRICKS_API_HOST; - -// if (typeof environmentId !== "string" || typeof apiHost !== "string") { -// throw new Error("Formbricks environment ID or API host is not defined"); -// } - -// return new FormbricksAPI({ -// environmentId, -// apiHost, -// }); -// }; - -// export const createResponse = async ( -// surveyId: string, -// userId: string, -// data: { [questionId: string]: any }, -// finished: boolean = false -// ): Promise => { -// const api = getFormbricksApi(); -// return await api.client.response.create({ -// surveyId, -// userId, -// finished, -// data, -// ttc, -// }); -// }; - -// export const updateResponse = async ( -// responseId: string, -// data: { [questionId: string]: any }, -// finished: boolean = false -// ): Promise => { -// const api = getFormbricksApi(); -// return await api.client.response.update({ -// responseId, -// finished, -// data, -// ttc, -// }); -// }; - export const formbricksLogout = async () => { return await formbricks.logout(); }; diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index 15a89a9339..9b8d040306 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -19,7 +19,12 @@ import type { TResponseTtc, TResponseVariables, } from "@formbricks/types/responses"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +interface VariableStackEntry { + questionId: string; + variables: TResponseVariables; +} export const Survey = ({ survey, @@ -68,7 +73,13 @@ export const Survey = ({ const [loadingElement, setLoadingElement] = useState(false); const [history, setHistory] = useState([]); const [responseData, setResponseData] = useState(hiddenFieldsRecord ?? {}); - const [responseVariables, setResponseVariables] = useState({}); + const [variableStack, setVariableStack] = useState([]); + const [currentVariables, setCurrentVariables] = useState(() => { + return localSurvey.variables.reduce((acc, variable) => { + acc[variable.id] = variable.value; + return acc; + }, {} as TResponseVariables); + }); const [ttc, setTtc] = useState({}); const questionIds = useMemo( @@ -154,8 +165,8 @@ export const Survey = ({ }; const onChangeVariables = (variables: TResponseVariables) => { - const updatedVariables = { ...responseVariables, ...variables }; - setResponseVariables(updatedVariables); + const updatedVariables = { ...currentVariables, ...variables }; + setCurrentVariables(updatedVariables); }; const makeQuestionsRequired = (questionIds: string[]): void => { @@ -169,30 +180,45 @@ export const Survey = ({ setlocalSurvey(localSurveyClone); }; - const getNextQuestionId = ( - survey: { questions: TSurveyQuestion[]; endings: { id: string }[] }, - questionId: string, - data: TResponseData, - selectedLanguage: string - ): string | undefined => { + const pushVariableState = (questionId: string) => { + setVariableStack((prevStack) => [...prevStack, { questionId, variables: { ...currentVariables } }]); + }; + + const popVariableState = () => { + setVariableStack(() => { + const newStack = [...variableStack]; + const poppedState = newStack.pop(); + if (poppedState) { + setCurrentVariables(poppedState.variables); + } + return newStack; + }); + }; + + const evaluateLogicAndGetNextQuestionId = ( + data: TResponseData + ): { nextQuestionId: string | undefined; calculatedVariables: TResponseVariables } => { const questions = survey.questions; const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined; - if (questionId === "start") return questions[0]?.id || firstEndingId; + if (questionId === "start") + return { nextQuestionId: questions[0]?.id || firstEndingId, calculatedVariables: {} }; - if (currIdxTemp === -1) throw new Error("Question not found"); + if (!currQuesTemp) throw new Error("Question not found"); let firstJumpTarget: string | undefined; const allRequiredQuestionIds: string[] = []; - const calculationResults: Record = {}; - if (currQuesTemp?.logic && currQuesTemp.logic.length > 0) { + let calculationResults = { ...currentVariables }; + + if (currQuesTemp.logic && currQuesTemp.logic.length > 0) { for (const logic of currQuesTemp.logic) { if (evaluateAdvancedLogic(localSurvey, data, logic.conditions, selectedLanguage)) { const { jumpTarget, requiredQuestionIds, calculations } = performActions( localSurvey, logic.actions, - data + data, + calculationResults ); if (jumpTarget && !firstJumpTarget) { @@ -200,36 +226,40 @@ export const Survey = ({ } allRequiredQuestionIds.push(...requiredQuestionIds); - Object.assign(calculationResults, calculations); + calculationResults = { ...calculationResults, ...calculations }; } } } - // Update response variables with all calculation results - onChangeVariables(calculationResults); - // Make all collected questions required if (allRequiredQuestionIds.length > 0) { makeQuestionsRequired(allRequiredQuestionIds); } // Return the first jump target if found, otherwise go to the next question or ending - return firstJumpTarget || questions[currentQuestionIndex + 1]?.id || firstEndingId; + const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || firstEndingId; + + return { nextQuestionId, calculatedVariables: calculationResults }; }; const onSubmit = (responseData: TResponseData, ttc: TResponseTtc) => { const questionId = Object.keys(responseData)[0]; setLoadingElement(true); - const nextQuestionId = getNextQuestionId(localSurvey, questionId, responseData, selectedLanguage); + + pushVariableState(questionId); + + const { nextQuestionId, calculatedVariables } = evaluateLogicAndGetNextQuestionId(responseData); const finished = nextQuestionId === undefined || !localSurvey.questions.map((question) => question.id).includes(nextQuestionId); + onChange(responseData); + onChangeVariables(calculatedVariables); onResponse({ data: responseData, ttc, finished, - variables: responseVariables, + variables: calculatedVariables, language: selectedLanguage, }); if (finished) { @@ -256,6 +286,7 @@ export const Survey = ({ // otherwise go back to previous question in array prevQuestionId = localSurvey.questions[currIdxTemp - 1]?.id; } + popVariableState(); if (!prevQuestionId) throw new Error("Question not found"); setQuestionId(prevQuestionId); }; diff --git a/packages/surveys/src/lib/logicEvaluator.ts b/packages/surveys/src/lib/logicEvaluator.ts index 85e820800c..0dd496984d 100644 --- a/packages/surveys/src/lib/logicEvaluator.ts +++ b/packages/surveys/src/lib/logicEvaluator.ts @@ -1,6 +1,6 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { isConditionsGroup } from "@formbricks/lib/survey/logic/utils"; -import { TResponseData } from "@formbricks/types/responses"; +import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; import { TAction, TActionCalculate, @@ -211,21 +211,22 @@ const getRightOperandValue = ( export const performActions = ( survey: TSurvey, actions: TAction[], - data: TResponseData + data: TResponseData, + calculationResults: TResponseVariables ): { jumpTarget: string | undefined; requiredQuestionIds: string[]; - calculations: Record; + calculations: TResponseVariables; } => { let jumpTarget: string | undefined; const requiredQuestionIds: string[] = []; - const calculations: Record = {}; + const calculations: TResponseVariables = { ...calculationResults }; actions.forEach((action) => { switch (action.objective) { case "calculate": - const result = performCalculation(survey, action, data); - if (result) calculations[action.variableId] = result; + const result = performCalculation(survey, action, data, calculations); + if (result !== undefined) calculations[action.variableId] = result; break; case "requireAnswer": requiredQuestionIds.push(action.target); @@ -244,14 +245,18 @@ export const performActions = ( const performCalculation = ( survey: TSurvey, action: TActionCalculate, - data: TResponseData + data: TResponseData, + calculations: Record ): number | string | undefined => { const variables = survey.variables || []; const variable = variables.find((v) => v.id === action.variableId); if (!variable) return undefined; - let currentValue = data[action.variableId] || variable.type === "number" ? 0 : ""; + let currentValue = calculations[action.variableId]; + if (currentValue === undefined) { + currentValue = variable.type === "number" ? 0 : ""; + } let operandValue: string | number | undefined; // Determine the operand value based on the action.value type @@ -259,8 +264,13 @@ const performCalculation = ( case "static": operandValue = action.value.value; break; - case "question": case "variable": + const value = calculations[action.value.value]; + if (typeof value === "number" || typeof value === "string") { + operandValue = value; + } + break; + case "question": case "hiddenField": const val = data[action.value.value]; if (typeof val === "number" || typeof val === "string") { @@ -272,7 +282,7 @@ const performCalculation = ( break; } - if (!operandValue) return undefined; + if (operandValue === undefined || operandValue === null) return undefined; let result: number | string;