From 45122de652960927b2244ce34ad3f02d3554a6c6 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 6 Nov 2025 19:10:58 +0530 Subject: [PATCH 1/3] surveys package changes for supporting blocks --- .../[environmentId]/environment/lib/data.ts | 1 + .../responses/[responseId]/lib/survey.ts | 1 + .../src/components/general/cal-embed.tsx | 4 +- .../src/components/general/headline.tsx | 3 +- .../src/components/general/progress-bar.tsx | 19 +- .../general/question-conditional.tsx | 78 ++++-- .../general/response-error-component.tsx | 4 +- .../src/components/general/subheader.tsx | 3 +- .../surveys/src/components/general/survey.tsx | 110 +++++--- .../src/components/general/welcome-card.tsx | 7 +- .../components/questions/address-question.tsx | 17 +- .../src/components/questions/cal-question.tsx | 15 +- .../components/questions/consent-question.tsx | 15 +- .../questions/contact-info-question.tsx | 15 +- .../src/components/questions/cta-question.tsx | 17 +- .../components/questions/date-question.tsx | 15 +- .../questions/file-upload-question.tsx | 15 +- .../components/questions/matrix-question.tsx | 21 +- .../multiple-choice-multi-question.tsx | 15 +- .../multiple-choice-single-question.tsx | 15 +- .../src/components/questions/nps-question.tsx | 15 +- .../questions/open-text-question.tsx | 15 +- .../questions/picture-selection-question.tsx | 15 +- .../components/questions/ranking-question.tsx | 20 +- .../components/questions/rating-question.tsx | 15 +- .../wrappers/stacked-cards-container.tsx | 23 +- packages/surveys/src/lib/logic.test.ts | 262 ++++++++++-------- packages/surveys/src/lib/logic.ts | 34 +-- packages/surveys/src/lib/recall.test.ts | 25 +- packages/surveys/src/lib/recall.ts | 10 +- packages/surveys/src/lib/ttc.test.ts | 5 +- packages/surveys/src/lib/ttc.ts | 5 +- packages/surveys/src/lib/utils.test.ts | 202 +++++++++++++- packages/surveys/src/lib/utils.ts | 87 ++++-- packages/types/js.ts | 1 + packages/types/surveys/types.ts | 40 +-- 36 files changed, 755 insertions(+), 409 deletions(-) diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts index 67dd557096..32fdb52884 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts @@ -92,6 +92,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise { select: { environmentId: true, questions: true, + blocks: true, }, }); diff --git a/packages/surveys/src/components/general/cal-embed.tsx b/packages/surveys/src/components/general/cal-embed.tsx index e93ac0fb99..0107078879 100644 --- a/packages/surveys/src/components/general/cal-embed.tsx +++ b/packages/surveys/src/components/general/cal-embed.tsx @@ -1,10 +1,10 @@ import snippet from "@calcom/embed-snippet"; import { useEffect, useMemo } from "preact/hooks"; -import { type TSurveyCalQuestion } from "@formbricks/types/surveys/types"; +import { type TSurveyCalElement } from "@formbricks/types/surveys/elements"; import { cn } from "@/lib/utils"; interface CalEmbedProps { - question: TSurveyCalQuestion; + question: TSurveyCalElement; onSuccessfulBooking: () => void; } diff --git a/packages/surveys/src/components/general/headline.tsx b/packages/surveys/src/components/general/headline.tsx index 644f283f0b..a6bcabec41 100644 --- a/packages/surveys/src/components/general/headline.tsx +++ b/packages/surveys/src/components/general/headline.tsx @@ -1,11 +1,10 @@ import DOMPurify from "isomorphic-dompurify"; import { useTranslation } from "react-i18next"; -import { type TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { isValidHTML } from "@/lib/html-utils"; interface HeadlineProps { headline: string; - questionId: TSurveyQuestionId; + questionId: string; required?: boolean; alignTextCenter?: boolean; } diff --git a/packages/surveys/src/components/general/progress-bar.tsx b/packages/surveys/src/components/general/progress-bar.tsx index b5a699357e..662d7f6dff 100644 --- a/packages/surveys/src/components/general/progress-bar.tsx +++ b/packages/surveys/src/components/general/progress-bar.tsx @@ -1,24 +1,25 @@ import { useCallback, useMemo } from "preact/hooks"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; -import { type TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { Progress } from "@/components/general/progress"; -import { calculateElementIdx } from "@/lib/utils"; +import { calculateElementIdx, getQuestionsFromSurvey } from "@/lib/utils"; interface ProgressBarProps { survey: TJsEnvironmentStateSurvey; - questionId: TSurveyQuestionId; + questionId: string; } export function ProgressBar({ survey, questionId }: ProgressBarProps) { + const questions = useMemo(() => getQuestionsFromSurvey(survey), [survey]); const currentQuestionIdx = useMemo( - () => survey.questions.findIndex((q) => q.id === questionId), - [survey, questionId] + () => questions.findIndex((q) => q.id === questionId), + [questions, questionId] ); + const endingCardIds = useMemo(() => survey.endings.map((ending) => ending.id), [survey.endings]); const calculateProgress = useCallback( (index: number) => { - let totalCards = survey.questions.length; + let totalCards = questions.length; if (endingCardIds.length > 0) totalCards += 1; let idx = index; @@ -27,12 +28,12 @@ export function ProgressBar({ survey, questionId }: ProgressBarProps) { const elementIdx = calculateElementIdx(survey, idx, totalCards); return elementIdx / totalCards; }, - [survey, endingCardIds.length] + [survey, questions.length, endingCardIds.length] ); const progressArray = useMemo(() => { - return survey.questions.map((_, index) => calculateProgress(index)); - }, [calculateProgress, survey]); + return questions.map((_, index) => calculateProgress(index)); + }, [calculateProgress, questions]); const progressValue = useMemo(() => { if (questionId === "start") { diff --git a/packages/surveys/src/components/general/question-conditional.tsx b/packages/surveys/src/components/general/question-conditional.tsx index 37c56b9917..f2f708fa4a 100644 --- a/packages/surveys/src/components/general/question-conditional.tsx +++ b/packages/surveys/src/components/general/question-conditional.tsx @@ -1,13 +1,9 @@ -import { useEffect } from "react"; -import { type TJsFileUploadParams } from "@formbricks/types/js"; +import { useEffect, useMemo } from "react"; +import { type TJsEnvironmentStateSurvey, type TJsFileUploadParams } from "@formbricks/types/js"; import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses"; import { type TUploadFileConfig } from "@formbricks/types/storage"; -import { - type TSurveyQuestion, - type TSurveyQuestionChoice, - type TSurveyQuestionId, - TSurveyQuestionTypeEnum, -} from "@formbricks/types/surveys/types"; +import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { type TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; import { AddressQuestion } from "@/components/questions/address-question"; import { CalQuestion } from "@/components/questions/cal-question"; import { ConsentQuestion } from "@/components/questions/consent-question"; @@ -24,9 +20,11 @@ import { PictureSelectionQuestion } from "@/components/questions/picture-selecti import { RankingQuestion } from "@/components/questions/ranking-question"; import { RatingQuestion } from "@/components/questions/rating-question"; import { getLocalizedValue } from "@/lib/i18n"; +import { findBlockByElementId } from "@/lib/utils"; interface QuestionConditionalProps { - question: TSurveyQuestion; + survey: TJsEnvironmentStateSurvey; + question: TSurveyElement; value: TResponseDataValue; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -41,7 +39,7 @@ interface QuestionConditionalProps { setTtc: (ttc: TResponseTtc) => void; surveyId: string; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; onOpenExternalURL?: (url: string) => void | Promise; dir?: "ltr" | "rtl" | "auto"; @@ -49,6 +47,7 @@ interface QuestionConditionalProps { } export function QuestionConditional({ + survey, question, value, onChange, @@ -70,6 +69,11 @@ export function QuestionConditional({ dir, fullSizeCards, }: QuestionConditionalProps) { + // Get block-level properties from the parent block + const parentBlock = useMemo(() => findBlockByElementId(survey, question.id), [survey, question.id]); + const buttonLabel = parentBlock?.buttonLabel; + const backButtonLabel = parentBlock?.backButtonLabel; + const getResponseValueForRankingQuestion = ( value: string[], choices: TSurveyQuestionChoice[] @@ -90,7 +94,7 @@ export function QuestionConditional({ // eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the question renders for the first time }, []); - return question.type === TSurveyQuestionTypeEnum.OpenText ? ( + return question.type === TSurveyElementTypeEnum.OpenText ? ( - ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? ( + ) : question.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? ( - ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? ( + ) : question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? ( - ) : question.type === TSurveyQuestionTypeEnum.NPS ? ( + ) : question.type === TSurveyElementTypeEnum.NPS ? ( - ) : question.type === TSurveyQuestionTypeEnum.CTA ? ( + ) : question.type === TSurveyElementTypeEnum.CTA ? ( - ) : question.type === TSurveyQuestionTypeEnum.Rating ? ( + ) : question.type === TSurveyElementTypeEnum.Rating ? ( - ) : question.type === TSurveyQuestionTypeEnum.Consent ? ( + ) : question.type === TSurveyElementTypeEnum.Consent ? ( - ) : question.type === TSurveyQuestionTypeEnum.Date ? ( + ) : question.type === TSurveyElementTypeEnum.Date ? ( - ) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? ( + ) : question.type === TSurveyElementTypeEnum.PictureSelection ? ( - ) : question.type === TSurveyQuestionTypeEnum.FileUpload ? ( + ) : question.type === TSurveyElementTypeEnum.FileUpload ? ( - ) : question.type === TSurveyQuestionTypeEnum.Cal ? ( + ) : question.type === TSurveyElementTypeEnum.Cal ? ( - ) : question.type === TSurveyQuestionTypeEnum.Matrix ? ( + ) : question.type === TSurveyElementTypeEnum.Matrix ? ( - ) : question.type === TSurveyQuestionTypeEnum.Address ? ( + ) : question.type === TSurveyElementTypeEnum.Address ? ( - ) : question.type === TSurveyQuestionTypeEnum.Ranking ? ( + ) : question.type === TSurveyElementTypeEnum.Ranking ? ( - ) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? ( + ) : question.type === TSurveyElementTypeEnum.ContactInfo ? ( ) : null; } diff --git a/packages/surveys/src/components/general/response-error-component.tsx b/packages/surveys/src/components/general/response-error-component.tsx index 799767bcf3..28aee5ea82 100644 --- a/packages/surveys/src/components/general/response-error-component.tsx +++ b/packages/surveys/src/components/general/response-error-component.tsx @@ -1,11 +1,11 @@ import { useTranslation } from "react-i18next"; import { type TResponseData } from "@formbricks/types/responses"; -import { type TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { SubmitButton } from "@/components/buttons/submit-button"; import { processResponseData } from "@/lib/response"; interface ResponseErrorComponentProps { - questions: TSurveyQuestion[]; + questions: TSurveyElement[]; responseData: TResponseData; onRetry?: () => void; } diff --git a/packages/surveys/src/components/general/subheader.tsx b/packages/surveys/src/components/general/subheader.tsx index 5355be6ba1..02e1f1bdfc 100644 --- a/packages/surveys/src/components/general/subheader.tsx +++ b/packages/surveys/src/components/general/subheader.tsx @@ -1,10 +1,9 @@ import DOMPurify from "isomorphic-dompurify"; -import { type TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { isValidHTML } from "@/lib/html-utils"; interface SubheaderProps { subheader?: string; - questionId: TSurveyQuestionId; + questionId: string; } export function Subheader({ subheader, questionId }: SubheaderProps) { diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index a6a01dc0c9..628ae4b117 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -10,8 +10,6 @@ import type { TResponseVariables, } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; -import { TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; -import { type TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { EndingCard } from "@/components/general/ending-card"; import { ErrorComponent } from "@/components/general/error-component"; import { FormbricksBranding } from "@/components/general/formbricks-branding"; @@ -29,11 +27,12 @@ import { evaluateLogic, performActions } from "@/lib/logic"; import { parseRecallInformation } from "@/lib/recall"; import { ResponseQueue } from "@/lib/response-queue"; import { SurveyState } from "@/lib/survey-state"; +import { findBlockByElementId, getFirstElementIdInBlock, getQuestionsFromSurvey } from "@/lib/utils"; import { cn, getDefaultLanguageCode } from "@/lib/utils"; import { TResponseErrorCodesEnum } from "@/types/response-error-codes"; interface VariableStackEntry { - questionId: TSurveyQuestionId; + questionId: string; variables: TResponseVariables; } @@ -142,12 +141,15 @@ export function Survey({ return null; }, [appUrl, environmentId, getSetIsError, getSetIsResponseSendingFinished, surveyState]); + const questions = useMemo(() => getQuestionsFromSurvey(localSurvey), [localSurvey]); + const originalQuestionRequiredStates = useMemo(() => { - return survey.questions.reduce>((acc, question) => { + return questions.reduce>((acc, question) => { acc[question.id] = question.required; return acc; }, {}); - }, [survey.questions]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only recompute when blocks structure changes + }, [survey.blocks]); // state to keep track of the questions that were made required by each specific question's logic const questionRequiredByMap = useRef>({}); @@ -174,7 +176,7 @@ export function Survey({ } else if (localSurvey.welcomeCard.enabled) { return "start"; } - return localSurvey.questions[0]?.id; + return questions[0]?.id; }); const [errorType, setErrorType] = useState(undefined); const [showError, setShowError] = useState(false); @@ -196,8 +198,8 @@ export function Survey({ return styling.cardArrangement?.appSurveys ?? "straight"; }, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]); - const currentQuestionIndex = localSurvey.questions.findIndex((q) => q.id === questionId); - const currentQuestion = localSurvey.questions[currentQuestionIndex]; + const currentQuestionIndex = questions.findIndex((q) => q.id === questionId); + const currentQuestion = questions[currentQuestionIndex]; const contentRef = useRef(null); const showProgressBar = !styling.hideProgressBar; @@ -345,15 +347,18 @@ export function Survey({ const makeQuestionsRequired = (requiredQuestionIds: string[]): void => { setlocalSurvey((prevSurvey) => ({ ...prevSurvey, - questions: prevSurvey.questions.map((question) => { - if (requiredQuestionIds.includes(question.id)) { - return { - ...question, - required: true, - }; - } - return question; - }), + blocks: prevSurvey.blocks.map((block) => ({ + ...block, + elements: block.elements.map((element) => { + if (requiredQuestionIds.includes(element.id)) { + return { + ...element, + required: true, + }; + } + return element; + }), + })), })); }; @@ -363,15 +368,18 @@ export function Survey({ if (questionsToRevert.length > 0) { setlocalSurvey((prevSurvey) => ({ ...prevSurvey, - questions: prevSurvey.questions.map((question) => { - if (questionsToRevert.includes(question.id)) { - return { - ...question, - required: originalQuestionRequiredStates[question.id] ?? question.required, - }; - } - return question; - }), + blocks: prevSurvey.blocks.map((block) => ({ + ...block, + elements: block.elements.map((element) => { + if (questionsToRevert.includes(element.id)) { + return { + ...element, + required: originalQuestionRequiredStates[element.id] ?? element.required, + }; + } + return element; + }), + })), })); // remove the question from the map @@ -379,7 +387,7 @@ export function Survey({ } }; - const pushVariableState = (currentQuestionId: TSurveyQuestionId) => { + const pushVariableState = (currentQuestionId: string) => { setVariableStack((prevStack) => [ ...prevStack, { questionId: currentQuestionId, variables: { ...currentVariables } }, @@ -399,8 +407,7 @@ export function Survey({ const evaluateLogicAndGetNextQuestionId = ( data: TResponseData - ): { nextQuestionId: TSurveyQuestionId | undefined; calculatedVariables: TResponseVariables } => { - const questions = survey.questions; + ): { nextQuestionId: string | undefined; calculatedVariables: TResponseVariables } => { const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined; if (questionId === "start") @@ -414,8 +421,10 @@ export function Survey({ let calculationResults = { ...currentVariables }; const localResponseData = { ...responseData, ...data }; - if (currentQuestion.logic && currentQuestion.logic.length > 0) { - for (const logic of currentQuestion.logic) { + // Get logic from the parent block (logic is at block level, not element level) + const currentQuestionBlock = findBlockByElementId(localSurvey, currentQuestion.id); + if (currentQuestionBlock?.logic && currentQuestionBlock.logic.length > 0) { + for (const logic of currentQuestionBlock.logic) { if ( evaluateLogic( localSurvey, @@ -427,7 +436,7 @@ export function Survey({ ) { const { jumpTarget, requiredQuestionIds, calculations } = performActions( localSurvey, - logic.actions as TSurveyBlockLogicAction[], // TODO: Temporary type assertion until the survey editor poc is completed, fix properly later + logic.actions, localResponseData, calculationResults ); @@ -442,9 +451,9 @@ export function Survey({ } } - // Use logicFallback if no jump target was set - if (!firstJumpTarget && currentQuestion.logicFallback) { - firstJumpTarget = currentQuestion.logicFallback; + // Use logicFallback if no jump target was set (logicFallback is at block level) + if (!firstJumpTarget && currentQuestionBlock?.logicFallback) { + firstJumpTarget = currentQuestionBlock.logicFallback; } if (allRequiredQuestionIds.length > 0) { @@ -454,8 +463,19 @@ export function Survey({ makeQuestionsRequired(allRequiredQuestionIds); } + // Convert block ID to element ID if jumping to a block + // (performActions returns block IDs for jumpToBlock actions) + let resolvedJumpTarget = firstJumpTarget; + if (firstJumpTarget) { + // Try to convert block ID to element ID + const elementId = getFirstElementIdInBlock(localSurvey, firstJumpTarget); + if (elementId) { + resolvedJumpTarget = elementId; + } + } + // Return the first jump target if found, otherwise go to the next question or ending - const nextQuestionId = firstJumpTarget ?? questions[currentQuestionIndex + 1]?.id ?? firstEndingId; + const nextQuestionId = resolvedJumpTarget ?? questions[currentQuestionIndex + 1]?.id ?? firstEndingId; return { nextQuestionId, calculatedVariables: calculationResults }; }; @@ -583,8 +603,7 @@ export function Survey({ const { nextQuestionId, calculatedVariables } = evaluateLogicAndGetNextQuestionId(surveyResponseData); const finished = - nextQuestionId === undefined || - !localSurvey.questions.map((question) => question.id).includes(nextQuestionId); + nextQuestionId === undefined || !questions.map((question) => question.id).includes(nextQuestionId); setIsSurveyFinished(finished); @@ -621,7 +640,7 @@ export function Survey({ setHistory(newHistory); } else { // otherwise go back to previous question in array - prevQuestionId = localSurvey.questions[currentQuestionIndex - 1]?.id; + prevQuestionId = questions[currentQuestionIndex - 1]?.id; } popVariableState(); if (!prevQuestionId) throw new Error("Question not found"); @@ -631,7 +650,7 @@ export function Survey({ }; const getQuestionPrefillData = ( - prefillQuestionId: TSurveyQuestionId, + prefillQuestionId: string, offset: number ): TResponseDataValue | undefined => { if (offset === 0 && prefillResponseData) { @@ -657,7 +676,7 @@ export function Survey({ return ( ); @@ -697,7 +716,7 @@ export function Survey({ fullSizeCards={fullSizeCards} /> ); - } else if (questionIdx >= localSurvey.questions.length) { + } else if (questionIdx >= questions.length) { const endingCard = localSurvey.endings.find((ending) => { return ending.id === questionId; }); @@ -720,11 +739,12 @@ export function Survey({ ); } } else { - const question = localSurvey.questions[questionIdx]; + const question = questions[questionIdx]; return ( Boolean(question) && ( { - let totalCards = survey.questions.length; + const questions = getQuestionsFromSurvey(survey); + let totalCards = questions.length; if (survey.endings.length > 0) totalCards += 1; let idx = calculateElementIdx(survey, 0, totalCards); if (idx === 0.5) { idx = 1; } - const timeInSeconds = (survey.questions.length / idx) * 15; //15 seconds per question. + const timeInSeconds = (questions.length / idx) * 15; //15 seconds per question. if (timeInSeconds > 360) { // If it's more than 6 minutes return t("common.x_plus_minutes", { count: 6 }); diff --git a/packages/surveys/src/components/questions/address-question.tsx b/packages/surveys/src/components/questions/address-question.tsx index 35a0375213..78cb133d6d 100644 --- a/packages/surveys/src/components/questions/address-question.tsx +++ b/packages/surveys/src/components/questions/address-question.tsx @@ -1,7 +1,8 @@ import { useMemo, useRef, useState } from "preact/hooks"; import { useCallback } from "react"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyAddressQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyAddressElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -14,7 +15,9 @@ import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface AddressQuestionProps { - question: TSurveyAddressQuestion; + question: TSurveyAddressElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value?: string[]; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -24,7 +27,7 @@ interface AddressQuestionProps { languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; autoFocusEnabled: boolean; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; @@ -33,6 +36,8 @@ interface AddressQuestionProps { export function AddressQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -184,14 +189,16 @@ export function AddressQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedttc); diff --git a/packages/surveys/src/components/questions/cal-question.tsx b/packages/surveys/src/components/questions/cal-question.tsx index a63e9019db..fa927af3ab 100644 --- a/packages/surveys/src/components/questions/cal-question.tsx +++ b/packages/surveys/src/components/questions/cal-question.tsx @@ -1,7 +1,8 @@ import { useCallback, useRef, useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyCalElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { CalEmbed } from "@/components/general/cal-embed"; @@ -16,7 +17,9 @@ import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface CalQuestionProps { - question: TSurveyCalQuestion; + question: TSurveyCalElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -27,13 +30,15 @@ interface CalQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; fullSizeCards: boolean; } export function CalQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -103,7 +108,7 @@ export function CalQuestion({
@@ -111,7 +116,7 @@ export function CalQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { onBack(); }} diff --git a/packages/surveys/src/components/questions/consent-question.tsx b/packages/surveys/src/components/questions/consent-question.tsx index f206fd0a39..e51273aacd 100644 --- a/packages/surveys/src/components/questions/consent-question.tsx +++ b/packages/surveys/src/components/questions/consent-question.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyConsentQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyConsentElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -11,7 +12,9 @@ import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface ConsentQuestionProps { - question: TSurveyConsentQuestion; + question: TSurveyConsentElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -22,7 +25,7 @@ interface ConsentQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -30,6 +33,8 @@ interface ConsentQuestionProps { export function ConsentQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -120,14 +125,14 @@ export function ConsentQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/questions/contact-info-question.tsx b/packages/surveys/src/components/questions/contact-info-question.tsx index 6b81231d7e..0ed52ea691 100644 --- a/packages/surveys/src/components/questions/contact-info-question.tsx +++ b/packages/surveys/src/components/questions/contact-info-question.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useRef, useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyContactInfoQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -13,7 +14,9 @@ import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface ContactInfoQuestionProps { - question: TSurveyContactInfoQuestion; + question: TSurveyContactInfoElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value?: string[]; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -24,7 +27,7 @@ interface ContactInfoQuestionProps { languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; autoFocusEnabled: boolean; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; @@ -33,6 +36,8 @@ interface ContactInfoQuestionProps { export function ContactInfoQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -183,13 +188,13 @@ export function ContactInfoQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedttc); diff --git a/packages/surveys/src/components/questions/cta-question.tsx b/packages/surveys/src/components/questions/cta-question.tsx index a78f981cba..1c5684e591 100644 --- a/packages/surveys/src/components/questions/cta-question.tsx +++ b/packages/surveys/src/components/questions/cta-question.tsx @@ -1,6 +1,7 @@ import { useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyCTAElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -11,7 +12,9 @@ import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface CTAQuestionProps { - question: TSurveyCTAQuestion; + question: TSurveyCTAElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -22,7 +25,7 @@ interface CTAQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; onOpenExternalURL?: (url: string) => void | Promise; fullSizeCards: boolean; @@ -30,6 +33,8 @@ interface CTAQuestionProps { export function CTAQuestion({ question, + buttonLabel, + backButtonLabel, onSubmit, onChange, onBack, @@ -68,7 +73,7 @@ export function CTAQuestion({
{ const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/questions/date-question.tsx b/packages/surveys/src/components/questions/date-question.tsx index be6414b08e..c38169e483 100644 --- a/packages/surveys/src/components/questions/date-question.tsx +++ b/packages/surveys/src/components/questions/date-question.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useState } from "preact/hooks"; import DatePicker, { DatePickerProps } from "react-date-picker"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyDateElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -16,7 +17,9 @@ import { cn } from "@/lib/utils"; import "../../styles/date-picker.css"; interface DateQuestionProps { - question: TSurveyDateQuestion; + question: TSurveyDateElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -28,7 +31,7 @@ interface DateQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; fullSizeCards: boolean; } @@ -84,6 +87,8 @@ function CalendarCheckIcon() { export function DateQuestion({ question, + buttonLabel, + backButtonLabel, value, onSubmit, onBack, @@ -275,12 +280,12 @@ export function DateQuestion({ {!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/questions/file-upload-question.tsx b/packages/surveys/src/components/questions/file-upload-question.tsx index d81b129bfe..23a673a9d2 100644 --- a/packages/surveys/src/components/questions/file-upload-question.tsx +++ b/packages/surveys/src/components/questions/file-upload-question.tsx @@ -1,9 +1,10 @@ import { useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TJsFileUploadParams } from "@formbricks/types/js"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import { type TUploadFileConfig } from "@formbricks/types/storage"; -import type { TSurveyFileUploadQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; @@ -15,7 +16,9 @@ import { FileInput } from "../general/file-input"; import { Subheader } from "../general/subheader"; interface FileUploadQuestionProps { - question: TSurveyFileUploadQuestion; + question: TSurveyFileUploadElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string[]; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -28,13 +31,15 @@ interface FileUploadQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; fullSizeCards: boolean; } export function FileUploadQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -108,13 +113,13 @@ export function FileUploadQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { onBack(); }} diff --git a/packages/surveys/src/components/questions/matrix-question.tsx b/packages/surveys/src/components/questions/matrix-question.tsx index eaa891fc8e..e8cba6232b 100644 --- a/packages/surveys/src/components/questions/matrix-question.tsx +++ b/packages/surveys/src/components/questions/matrix-question.tsx @@ -1,11 +1,8 @@ import { type JSX } from "preact"; import { useCallback, useMemo, useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { - TSurveyMatrixQuestion, - TSurveyMatrixQuestionChoice, - TSurveyQuestionId, -} from "@formbricks/types/surveys/types"; +import type { TSurveyMatrixElement, TSurveyMatrixElementChoice } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -17,7 +14,9 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { getShuffledRowIndices } from "@/lib/utils"; interface MatrixQuestionProps { - question: TSurveyMatrixQuestion; + question: TSurveyMatrixElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: Record; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -27,13 +26,15 @@ interface MatrixQuestionProps { languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; fullSizeCards: boolean; } export function MatrixQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -77,7 +78,7 @@ export function MatrixQuestion({ let responseValue = Object.entries(value).length !== 0 ? { ...value } - : question.rows.reduce((obj: Record, row: TSurveyMatrixQuestionChoice) => { + : question.rows.reduce((obj: Record, row: TSurveyMatrixElementChoice) => { obj[getLocalizedValue(row.label, languageCode)] = ""; // Initialize each row key with an empty string return obj; }, {}); @@ -203,13 +204,13 @@ export function MatrixQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx index 8c9ebce9c3..1ad565b02b 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -12,7 +13,9 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn, getShuffledChoicesIds } from "@/lib/utils"; interface MultipleChoiceMultiProps { - question: TSurveyMultipleChoiceQuestion; + question: TSurveyMultipleChoiceElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string[]; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -23,7 +26,7 @@ interface MultipleChoiceMultiProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -31,6 +34,8 @@ interface MultipleChoiceMultiProps { export function MultipleChoiceMultiQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -378,13 +383,13 @@ export function MultipleChoiceMultiQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx index 5fce308282..20397bde66 100644 --- a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -12,7 +13,9 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn, getShuffledChoicesIds } from "@/lib/utils"; interface MultipleChoiceSingleProps { - question: TSurveyMultipleChoiceQuestion; + question: TSurveyMultipleChoiceElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value?: string; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -23,7 +26,7 @@ interface MultipleChoiceSingleProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -31,6 +34,8 @@ interface MultipleChoiceSingleProps { export function MultipleChoiceSingleQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -315,12 +320,12 @@ export function MultipleChoiceSingleQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); diff --git a/packages/surveys/src/components/questions/nps-question.tsx b/packages/surveys/src/components/questions/nps-question.tsx index 6e4a0be9d2..4e7a9e2726 100644 --- a/packages/surveys/src/components/questions/nps-question.tsx +++ b/packages/surveys/src/components/questions/nps-question.tsx @@ -1,6 +1,7 @@ import { useState } from "preact/hooks"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyNPSQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyNPSElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -12,7 +13,9 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn } from "@/lib/utils"; interface NPSQuestionProps { - question: TSurveyNPSQuestion; + question: TSurveyNPSElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value?: number; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -23,7 +26,7 @@ interface NPSQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -31,6 +34,8 @@ interface NPSQuestionProps { export function NPSQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -170,14 +175,14 @@ export function NPSQuestion({ ) : ( )} {!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/questions/open-text-question.tsx b/packages/surveys/src/components/questions/open-text-question.tsx index dd9e53bb09..9014a02c9f 100644 --- a/packages/surveys/src/components/questions/open-text-question.tsx +++ b/packages/surveys/src/components/questions/open-text-question.tsx @@ -2,8 +2,9 @@ import { type RefObject } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; import { ZEmail, ZUrl } from "@formbricks/types/common"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -14,7 +15,9 @@ import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface OpenTextQuestionProps { - question: TSurveyOpenTextQuestion; + question: TSurveyOpenTextElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -26,7 +29,7 @@ interface OpenTextQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -34,6 +37,8 @@ interface OpenTextQuestionProps { export function OpenTextQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -196,14 +201,14 @@ export function OpenTextQuestion({
{}} /> {!isFirstQuestion && !isBackButtonHidden && ( { const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedttc); diff --git a/packages/surveys/src/components/questions/picture-selection-question.tsx b/packages/surveys/src/components/questions/picture-selection-question.tsx index 51adbd4485..16b7317025 100644 --- a/packages/surveys/src/components/questions/picture-selection-question.tsx +++ b/packages/surveys/src/components/questions/picture-selection-question.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyPictureSelectionQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import type { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -15,7 +16,9 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn } from "@/lib/utils"; interface PictureSelectionProps { - question: TSurveyPictureSelectionQuestion; + question: TSurveyPictureSelectionElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string[]; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -26,7 +29,7 @@ interface PictureSelectionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -34,6 +37,8 @@ interface PictureSelectionProps { export function PictureSelectionQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -221,13 +226,13 @@ export function PictureSelectionQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/questions/ranking-question.tsx b/packages/surveys/src/components/questions/ranking-question.tsx index af0a1fd44e..88ad874bef 100644 --- a/packages/surveys/src/components/questions/ranking-question.tsx +++ b/packages/surveys/src/components/questions/ranking-question.tsx @@ -1,12 +1,10 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useCallback, useMemo, useRef, useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { - TSurveyQuestionChoice, - TSurveyQuestionId, - TSurveyRankingQuestion, -} from "@formbricks/types/surveys/types"; +import type { TSurveyRankingElement } from "@formbricks/types/surveys/elements"; +import type { TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -21,7 +19,9 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn, getShuffledChoicesIds } from "@/lib/utils"; interface RankingQuestionProps { - question: TSurveyRankingQuestion; + question: TSurveyRankingElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value: string[]; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -32,13 +32,15 @@ interface RankingQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; fullSizeCards: boolean; } export function RankingQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -294,12 +296,12 @@ export function RankingQuestion({
{!isFirstQuestion && !isBackButtonHidden && ( diff --git a/packages/surveys/src/components/questions/rating-question.tsx b/packages/surveys/src/components/questions/rating-question.tsx index 4e0a3019fc..9dcde22b82 100644 --- a/packages/surveys/src/components/questions/rating-question.tsx +++ b/packages/surveys/src/components/questions/rating-question.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "preact/hooks"; import type { JSX } from "react"; +import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyQuestionId, TSurveyRatingQuestion } from "@formbricks/types/surveys/types"; +import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements"; import { BackButton } from "@/components/buttons/back-button"; import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; @@ -25,7 +26,9 @@ import { import { Subheader } from "../general/subheader"; interface RatingQuestionProps { - question: TSurveyRatingQuestion; + question: TSurveyRatingElement; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; value?: number; onChange: (responseData: TResponseData) => void; onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; @@ -36,7 +39,7 @@ interface RatingQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; fullSizeCards: boolean; @@ -44,6 +47,8 @@ interface RatingQuestionProps { export function RatingQuestion({ question, + buttonLabel, + backButtonLabel, value, onChange, onSubmit, @@ -273,7 +278,7 @@ export function RatingQuestion({ ) : ( )} @@ -281,7 +286,7 @@ export function RatingQuestion({ {!isFirstQuestion && !isBackButtonHidden && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); diff --git a/packages/surveys/src/components/wrappers/stacked-cards-container.tsx b/packages/surveys/src/components/wrappers/stacked-cards-container.tsx index 9409024674..e13643ae1f 100644 --- a/packages/surveys/src/components/wrappers/stacked-cards-container.tsx +++ b/packages/surveys/src/components/wrappers/stacked-cards-container.tsx @@ -3,8 +3,8 @@ import type { JSX } from "react"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { type TProjectStyling } from "@formbricks/types/project"; import { type TCardArrangementOptions } from "@formbricks/types/styling"; -import { type TSurveyQuestionId, type TSurveyStyling } from "@formbricks/types/surveys/types"; -import { cn } from "@/lib/utils"; +import { TSurveyStyling } from "@formbricks/types/surveys/types"; +import { cn, getQuestionsFromSurvey } from "@/lib/utils"; import { StackedCard } from "./stacked-card"; // offset = 0 -> Current question card @@ -12,11 +12,11 @@ import { StackedCard } from "./stacked-card"; // offset > 0 -> Question that aren't answered yet interface StackedCardsContainerProps { cardArrangement: TCardArrangementOptions; - currentQuestionId: TSurveyQuestionId; + currentQuestionId: string; survey: TJsEnvironmentStateSurvey; getCardContent: (questionIdxTemp: number, offset: number) => JSX.Element | undefined; styling: TProjectStyling | TSurveyStyling; - setQuestionId: (questionId: TSurveyQuestionId) => void; + setQuestionId: (questionId: string) => void; shouldResetQuestionId?: boolean; fullSizeCards: boolean; } @@ -43,14 +43,15 @@ export function StackedCardsContainer({ const [cardHeight, setCardHeight] = useState("auto"); const [cardWidth, setCardWidth] = useState(0); + const questions = useMemo(() => getQuestionsFromSurvey(survey), [survey]); + const questionIdxTemp = useMemo(() => { if (currentQuestionId === "start") return survey.welcomeCard.enabled ? -1 : 0; - if (!survey.questions.map((question) => question.id).includes(currentQuestionId)) { - return survey.questions.length; + if (!questions.map((question) => question.id).includes(currentQuestionId)) { + return questions.length; } - return survey.questions.findIndex((question) => question.id === currentQuestionId); - // eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when currentQuestionId changes - }, [currentQuestionId, survey.welcomeCard.enabled, survey.questions.length]); + return questions.findIndex((question) => question.id === currentQuestionId); + }, [currentQuestionId, survey, questions]); const [prevQuestionIdx, setPrevQuestionIdx] = useState(questionIdxTemp - 1); const [currentQuestionIdx, setCurrentQuestionIdx] = useState(questionIdxTemp); @@ -134,7 +135,7 @@ export function StackedCardsContainer({ // Reset question progress, when card arrangement changes useEffect(() => { if (shouldResetQuestionId) { - setQuestionId(survey.welcomeCard.enabled ? "start" : survey.questions[0]?.id); + setQuestionId(survey.welcomeCard.enabled ? "start" : questions[0]?.id); } // eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when cardArrangement changes }, [cardArrangement]); @@ -164,7 +165,7 @@ export function StackedCardsContainer({ (dynamicQuestionIndex, index) => { const hasEndingCard = survey.endings.length > 0; // Check for hiding extra card - if (dynamicQuestionIndex > survey.questions.length + (hasEndingCard ? 0 : -1)) return; + if (dynamicQuestionIndex > questions.length + (hasEndingCard ? 0 : -1)) return; const offset = index - 1; return ( { const mockSurvey: TJsEnvironmentStateSurvey = { id: "survey1", name: "Test Survey", - questions: [ + questions: [], // Deprecated - using blocks instead + blocks: [ { - id: "q1", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 1" }, - subheader: { default: "Enter some text" }, - required: true, - inputType: "text", - charLimit: { enabled: false }, - }, - { - id: "q2", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 2" }, - subheader: { default: "Enter a number" }, - required: true, - inputType: "number", - charLimit: { enabled: false }, - }, - { - id: "q3", - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: "Question 3" }, - subheader: { default: "Select one option" }, - required: true, - choices: [ - { id: "opt1", label: { default: "Option 1", es: "Opción 1" } }, - { id: "opt2", label: { default: "Option 2", es: "Opción 2" } }, - { id: "other", label: { default: "Other", es: "Otro" } }, + id: "block1", + name: "Block 1", + elements: [ + { + id: "q1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Question 1" }, + subheader: { default: "Enter some text" }, + required: true, + inputType: "text", + charLimit: { enabled: false }, + }, + { + id: "q2", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Question 2" }, + subheader: { default: "Enter a number" }, + required: true, + inputType: "number", + charLimit: { enabled: false }, + }, + { + id: "q3", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 3" }, + subheader: { default: "Select one option" }, + required: true, + choices: [ + { id: "opt1", label: { default: "Option 1", es: "Opción 1" } }, + { id: "opt2", label: { default: "Option 2", es: "Opción 2" } }, + { id: "other", label: { default: "Other", es: "Otro" } }, + ], + }, + { + id: "q4", + type: TSurveyElementTypeEnum.MultipleChoiceMulti, + headline: { default: "Question 4" }, + subheader: { default: "Select multiple options" }, + required: true, + choices: [ + { id: "opt1", label: { default: "Option 1", es: "Opción 1" } }, + { id: "opt2", label: { default: "Option 2", es: "Opción 2" } }, + { id: "opt3", label: { default: "Option 3", es: "Opción 3" } }, + ], + }, + { + id: "q5", + type: TSurveyElementTypeEnum.Date, + headline: { default: "Question 5" }, + subheader: { default: "Select a date" }, + required: true, + format: "d-M-y", + }, + { + id: "q6", + type: TSurveyElementTypeEnum.FileUpload, + headline: { default: "Question 6" }, + subheader: { default: "Upload a file" }, + required: true, + allowMultipleFiles: true, + }, + { + id: "q7", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Question 7" }, + subheader: { default: "Select pictures" }, + required: true, + allowMulti: true, + choices: [ + { id: "pic1", imageUrl: "url1" }, + { id: "pic2", imageUrl: "url2" }, + ], + }, + { + id: "q8", + type: TSurveyElementTypeEnum.Matrix, + headline: { default: "Question 8" }, + subheader: { default: "Matrix question" }, + required: true, + rows: [ + { id: "row1", label: { default: "Row 1", es: "Fila 1" } }, + { id: "row2", label: { default: "Row 2", es: "Fila 2" } }, + ], + columns: [ + { id: "col1", label: { default: "Column 1", es: "Columna 1" } }, + { id: "col2", label: { default: "Column 2", es: "Columna 2" } }, + ], + shuffleOption: "none", + }, ], }, - { - id: "q4", - type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { default: "Question 4" }, - subheader: { default: "Select multiple options" }, - required: true, - choices: [ - { id: "opt1", label: { default: "Option 1", es: "Opción 1" } }, - { id: "opt2", label: { default: "Option 2", es: "Opción 2" } }, - { id: "opt3", label: { default: "Option 3", es: "Opción 3" } }, - ], - }, - { - id: "q5", - type: TSurveyQuestionTypeEnum.Date, - headline: { default: "Question 5" }, - subheader: { default: "Select a date" }, - required: true, - format: "d-M-y", - }, - { - id: "q6", - type: TSurveyQuestionTypeEnum.FileUpload, - headline: { default: "Question 6" }, - subheader: { default: "Upload a file" }, - required: true, - allowMultipleFiles: true, - }, - { - id: "q7", - type: TSurveyQuestionTypeEnum.PictureSelection, - headline: { default: "Question 7" }, - subheader: { default: "Select pictures" }, - required: true, - allowMulti: true, - choices: [ - { id: "pic1", imageUrl: "url1" }, - { id: "pic2", imageUrl: "url2" }, - ], - }, - { - id: "q8", - type: TSurveyQuestionTypeEnum.Matrix, - headline: { default: "Question 8" }, - subheader: { default: "Matrix question" }, - required: true, - rows: [ - { id: "row1", label: { default: "Row 1", es: "Fila 1" } }, - { id: "row2", label: { default: "Row 2", es: "Fila 2" } }, - ], - columns: [ - { id: "col1", label: { default: "Column 1", es: "Columna 1" } }, - { id: "col2", label: { default: "Column 2", es: "Columna 2" } }, - ], - shuffleOption: "none", - }, ], variables: mockVariables, hiddenFields: { @@ -1180,21 +1188,27 @@ describe("Survey Logic", () => { // Mock survey with date questions const dateSurvey: TJsEnvironmentStateSurvey = { ...mockSurvey, - questions: [ - ...mockSurvey.questions, + blocks: [ + ...mockSurvey.blocks, { - id: "dateQ1", - type: TSurveyQuestionTypeEnum.Date, - headline: { default: "Date Question 1" }, - required: true, - format: "d-M-y", - }, - { - id: "dateQ2", - type: TSurveyQuestionTypeEnum.Date, - headline: { default: "Date Question 2" }, - required: true, - format: "d-M-y", + id: "dateBlock", + name: "Date Block", + elements: [ + { + id: "dateQ1", + type: TSurveyElementTypeEnum.Date, + headline: { default: "Date Question 1" }, + required: true, + format: "d-M-y", + }, + { + id: "dateQ2", + type: TSurveyElementTypeEnum.Date, + headline: { default: "Date Question 2" }, + required: true, + format: "d-M-y", + }, + ], }, ], }; @@ -1230,16 +1244,22 @@ describe("Survey Logic", () => { const multiSurvey: TJsEnvironmentStateSurvey = { ...mockSurvey, - questions: [ - ...mockSurvey.questions, + blocks: [ + ...mockSurvey.blocks, { - id: "multiQ", - type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { default: "Multiple Choice" }, - required: true, - choices: [ - { id: "opt1", label: { default: "Option 1" } }, - { id: "opt2", label: { default: "Option 2" } }, + id: "multiBlock", + name: "Multi Choice Block", + elements: [ + { + id: "multiQ", + type: TSurveyElementTypeEnum.MultipleChoiceMulti, + headline: { default: "Multiple Choice" }, + required: true, + choices: [ + { id: "opt1", label: { default: "Option 1" } }, + { id: "opt2", label: { default: "Option 2" } }, + ], + }, ], }, ], @@ -1353,17 +1373,23 @@ describe("Survey Logic", () => { test("getLeftOperandValue with edge cases", () => { const specialSurvey: TJsEnvironmentStateSurvey = { ...mockSurvey, - questions: [ - ...mockSurvey.questions, + blocks: [ + ...mockSurvey.blocks, { - id: "multiChoiceWithOther", - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: "Multiple Choice With Other" }, - required: true, - choices: [ - { id: "opt1", label: { default: "Option 1" } }, - { id: "opt2", label: { default: "Option 2" } }, - { id: "other", label: { default: "Other" } }, + id: "specialBlock", + name: "Special Block", + elements: [ + { + id: "multiChoiceWithOther", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + headline: { default: "Multiple Choice With Other" }, + required: true, + choices: [ + { id: "opt1", label: { default: "Option 1" } }, + { id: "opt2", label: { default: "Option 2" } }, + { id: "other", label: { default: "Other" } }, + ], + }, ], }, ], diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts index 8a40636b0e..80ae556484 100644 --- a/packages/surveys/src/lib/logic.ts +++ b/packages/surveys/src/lib/logic.ts @@ -1,9 +1,11 @@ import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; import { TActionCalculate, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; +import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic"; -import { TSurveyQuestion, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types"; +import { TSurveyVariable } from "@formbricks/types/surveys/types"; import { getLocalizedValue } from "@/lib/i18n"; +import { getQuestionsFromSurvey } from "./utils"; const getVariableValue = ( variables: TSurveyVariable[], @@ -87,7 +89,8 @@ const getLeftOperandValue = ( ) => { switch (leftOperand.type) { case "question": - const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value); + const questions = getQuestionsFromSurvey(localSurvey); + const currentQuestion = questions.find((q) => q.id === leftOperand.value); if (!currentQuestion) return undefined; const responseValue = data[leftOperand.value]; @@ -218,10 +221,11 @@ const evaluateSingleCondition = ( ? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand) : undefined; - let leftField: TSurveyQuestion | TSurveyVariable | string; + let leftField: TSurveyElement | TSurveyVariable | string; + const questions = getQuestionsFromSurvey(localSurvey); if (condition.leftOperand?.type === "question") { - leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion; + leftField = questions.find((q) => q.id === condition.leftOperand?.value) ?? ""; } else if (condition.leftOperand?.type === "variable") { leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable; } else if (condition.leftOperand?.type === "hiddenField") { @@ -230,12 +234,10 @@ const evaluateSingleCondition = ( leftField = ""; } - let rightField: TSurveyQuestion | TSurveyVariable | string; + let rightField: TSurveyElement | TSurveyVariable | string; if (condition.rightOperand?.type === "question") { - rightField = localSurvey.questions.find( - (q) => q.id === condition.rightOperand?.value - ) as TSurveyQuestion; + rightField = questions.find((q) => q.id === condition.rightOperand?.value) ?? ""; } else if (condition.rightOperand?.type === "variable") { rightField = localSurvey.variables.find( (v) => v.id === condition.rightOperand?.value @@ -258,7 +260,7 @@ const evaluateSingleCondition = ( case "equals": if (condition.leftOperand.type === "question") { if ( - (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + (leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date && typeof leftValue === "string" && typeof rightValue === "string" ) { @@ -269,12 +271,12 @@ const evaluateSingleCondition = ( // when left value is of openText, hiddenField, variable and right value is of multichoice if (condition.rightOperand?.type === "question") { - if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) { + if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) { if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { return rightValue.includes(leftValue as string); } else return false; } else if ( - (rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + (rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date && typeof leftValue === "string" && typeof rightValue === "string" ) { @@ -293,7 +295,7 @@ const evaluateSingleCondition = ( // when left value is of picture selection question and right value is its option if ( condition.leftOperand.type === "question" && - (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection && + (leftField as TSurveyElement).type === TSurveyElementTypeEnum.PictureSelection && Array.isArray(leftValue) && leftValue.length > 0 && typeof rightValue === "string" @@ -304,7 +306,7 @@ const evaluateSingleCondition = ( // when left value is of date question and right value is string if ( condition.leftOperand.type === "question" && - (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + (leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date && typeof leftValue === "string" && typeof rightValue === "string" ) { @@ -313,12 +315,12 @@ const evaluateSingleCondition = ( // when left value is of openText, hiddenField, variable and right value is of multichoice if (condition.rightOperand?.type === "question") { - if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) { + if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) { if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { return !rightValue.includes(leftValue as string); } else return false; } else if ( - (rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + (rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date && typeof leftValue === "string" && typeof rightValue === "string" ) { @@ -349,7 +351,7 @@ const evaluateSingleCondition = ( if (typeof leftValue === "string") { if ( condition.leftOperand.type === "question" && - (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload && + (leftField as TSurveyElement).type === TSurveyElementTypeEnum.FileUpload && leftValue ) { return leftValue !== "skipped"; diff --git a/packages/surveys/src/lib/recall.test.ts b/packages/surveys/src/lib/recall.test.ts index 6381701ab3..f461ed59de 100644 --- a/packages/surveys/src/lib/recall.test.ts +++ b/packages/surveys/src/lib/recall.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses"; -import { type TSurveyQuestion, TSurveyQuestionTypeEnum } from "../../../types/surveys/types"; +import { TSurveyElementTypeEnum, type TSurveyOpenTextElement } from "@formbricks/types/surveys/elements"; import { parseRecallInformation, replaceRecallInfo } from "./recall"; // Mock getLocalizedValue (assuming path and simple behavior) @@ -153,18 +153,17 @@ describe("parseRecallInformation", () => { surveyType: "Onboarding", }; - const baseQuestion: TSurveyQuestion = { + const baseQuestion: TSurveyOpenTextElement = { id: "survey1", - type: TSurveyQuestionTypeEnum.OpenText, + type: TSurveyElementTypeEnum.OpenText, headline: { en: "Original Headline" }, required: false, inputType: "text", charLimit: { enabled: false }, - // other necessary TSurveyQuestion fields can be added here with default values }; test("should replace recall info in headline", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Welcome, #recall:name/fallback:Guest#!" }, }; @@ -174,7 +173,7 @@ describe("parseRecallInformation", () => { }); test("should replace recall info in subheader", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Main Question" }, subheader: { en: "Details: #recall:productName/fallback:N/A#." }, @@ -185,7 +184,7 @@ describe("parseRecallInformation", () => { }); test("should replace recall info in both headline and subheader", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "User: #recall:name/fallback:User#" }, subheader: { en: "Survey: #recall:surveyType/fallback:General#" }, @@ -196,7 +195,7 @@ describe("parseRecallInformation", () => { }); test("should not change text if no recall info is present", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "A simple question." }, subheader: { en: "With a simple subheader." }, @@ -212,7 +211,7 @@ describe("parseRecallInformation", () => { }); test("should handle undefined subheader gracefully", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Question with #recall:name/fallback:User#" }, subheader: undefined, @@ -223,7 +222,7 @@ describe("parseRecallInformation", () => { }); test("should not modify subheader if languageCode content is missing, even if recall is in other lang", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Hello #recall:name/fallback:User#" }, subheader: { fr: "Bonjour #recall:name/fallback:Utilisateur#", en: "" }, @@ -237,7 +236,7 @@ describe("parseRecallInformation", () => { test("should handle malformed recall string (empty ID) leading to no replacement for that pattern", () => { // This tests extractId returning null because extractRecallInfo won't match '#recall:/fallback:foo#' // due to idPattern requiring at least one char for ID. - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Malformed: #recall:/fallback:foo# and valid: #recall:name/fallback:User#" }, }; @@ -247,7 +246,7 @@ describe("parseRecallInformation", () => { test("should use empty string for empty fallback value", () => { // This tests extractFallbackValue returning "" - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Data: #recall:nonExistentData/fallback:#" }, }; @@ -256,7 +255,7 @@ describe("parseRecallInformation", () => { }); test("should handle recall info if subheader is present but no text for languageCode", () => { - const question: TSurveyQuestion = { + const question: TSurveyOpenTextElement = { ...baseQuestion, headline: { en: "Headline #recall:name/fallback:User#" }, subheader: { fr: "French subheader #recall:productName/fallback:Produit#", en: "" }, diff --git a/packages/surveys/src/lib/recall.ts b/packages/surveys/src/lib/recall.ts index 79a2393694..1be78d67c9 100644 --- a/packages/surveys/src/lib/recall.ts +++ b/packages/surveys/src/lib/recall.ts @@ -1,5 +1,5 @@ import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses"; -import { type TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { formatDateWithOrdinal, isValidDateString } from "@/lib/date-time"; import { getLocalizedValue } from "@/lib/i18n"; @@ -68,12 +68,12 @@ export const replaceRecallInfo = ( return modifiedText; }; -export const parseRecallInformation = ( - question: TSurveyQuestion, +export const parseRecallInformation = ( + question: T, languageCode: string, responseData: TResponseData, variables: TResponseVariables -) => { +): T => { const modifiedQuestion = JSON.parse(JSON.stringify(question)); if (question.headline[languageCode].includes("recall:")) { modifiedQuestion.headline[languageCode] = replaceRecallInfo( @@ -94,7 +94,7 @@ export const parseRecallInformation = ( ); } if ( - (question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent) && + (question.type === TSurveyElementTypeEnum.CTA || question.type === TSurveyElementTypeEnum.Consent) && question.subheader && question.subheader[languageCode].includes("recall:") && modifiedQuestion.subheader diff --git a/packages/surveys/src/lib/ttc.test.ts b/packages/surveys/src/lib/ttc.test.ts index 97ea72c693..cab8602b7b 100644 --- a/packages/surveys/src/lib/ttc.test.ts +++ b/packages/surveys/src/lib/ttc.test.ts @@ -1,7 +1,6 @@ import { act, renderHook } from "@testing-library/preact"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TResponseTtc } from "@formbricks/types/responses"; -import { TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { getUpdatedTtc, useTtc } from "./ttc"; describe("getUpdatedTtc", () => { @@ -31,7 +30,7 @@ describe("useTtc", () => { let mockSetStartTime: ReturnType; let currentTime = 0; let initialProps: { - questionId: TSurveyQuestionId; + questionId: string; ttc: TResponseTtc; setTtc: ReturnType; startTime: number; @@ -48,7 +47,7 @@ describe("useTtc", () => { vi.spyOn(document, "removeEventListener"); initialProps = { - questionId: "q1" as TSurveyQuestionId, + questionId: "q1", ttc: {} as TResponseTtc, setTtc: mockSetTtc, startTime: 0, diff --git a/packages/surveys/src/lib/ttc.ts b/packages/surveys/src/lib/ttc.ts index a348a69403..2fd2c2bd96 100644 --- a/packages/surveys/src/lib/ttc.ts +++ b/packages/surveys/src/lib/ttc.ts @@ -1,8 +1,7 @@ import { useEffect } from "react"; import { type TResponseTtc } from "@formbricks/types/responses"; -import { type TSurveyQuestionId } from "@formbricks/types/surveys/types"; -export const getUpdatedTtc = (ttc: TResponseTtc, questionId: TSurveyQuestionId, time: number) => { +export const getUpdatedTtc = (ttc: TResponseTtc, questionId: string, time: number) => { // Check if the question ID already exists if (questionId in ttc) { return { @@ -18,7 +17,7 @@ export const getUpdatedTtc = (ttc: TResponseTtc, questionId: TSurveyQuestionId, }; export const useTtc = ( - questionId: TSurveyQuestionId, + questionId: string, ttc: TResponseTtc, setTtc: (ttc: TResponseTtc) => void, startTime: number, diff --git a/packages/surveys/src/lib/utils.test.ts b/packages/surveys/src/lib/utils.test.ts index bcafc11e14..6847ae0ff1 100644 --- a/packages/surveys/src/lib/utils.test.ts +++ b/packages/surveys/src/lib/utils.test.ts @@ -1,8 +1,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { TJsEnvironmentStateSurvey } from "../../../types/js"; import { type TAllowedFileExtension, mimeTypes } from "../../../types/storage"; +import { TSurveyElementTypeEnum } from "../../../types/surveys/elements"; import type { TSurveyLanguage, TSurveyQuestionChoice } from "../../../types/surveys/types"; -import { getDefaultLanguageCode, getMimeType, getShuffledChoicesIds, getShuffledRowIndices } from "./utils"; +import { + findBlockByElementId, + getDefaultLanguageCode, + getMimeType, + getQuestionsFromSurvey, + getShuffledChoicesIds, + getShuffledRowIndices, +} from "./utils"; // Mock crypto.getRandomValues for deterministic shuffle tests const mockGetRandomValues = vi.fn(); @@ -19,6 +27,29 @@ describe("getMimeType", () => { }); }); +// Base mock for TJsEnvironmentStateSurvey to satisfy stricter type checks +const baseMockSurvey: TJsEnvironmentStateSurvey = { + id: "survey1", + name: "Test Survey", + type: "link", + status: "inProgress", + questions: [], + blocks: [], + endings: [], + welcomeCard: { enabled: false, timeToFinish: true, showResponseCount: false }, + variables: [], + styling: { overwriteThemeStyling: false }, + recontactDays: null, + displayLimit: null, + displayPercentage: null, + languages: [], + segment: null, + hiddenFields: { enabled: false, fieldIds: [] }, + projectOverwrites: null, + triggers: [], + displayOption: "displayOnce", +} as unknown as TJsEnvironmentStateSurvey; + describe("getDefaultLanguageCode", () => { const mockSurveyLanguageEn: TSurveyLanguage = { default: true, @@ -45,20 +76,6 @@ describe("getDefaultLanguageCode", () => { }, }; - // Base mock for TJsEnvironmentStateSurvey to satisfy stricter type checks - const baseMockSurvey: Partial = { - id: "survey1", - name: "Test Survey", - type: "link", // Corrected: 'link' or 'app' - status: "inProgress", // Assuming 'inProgress' is a valid TSurveyStatus - questions: [], - endings: [], - welcomeCard: { enabled: false, timeToFinish: true, showResponseCount: false }, // Added missing properties - variables: [], - styling: { overwriteThemeStyling: false }, - // ... other mandatory fields with default/mock values if needed - }; - test("should return the code of the default language", () => { const survey: TJsEnvironmentStateSurvey = { ...baseMockSurvey, @@ -164,3 +181,158 @@ describe("getShuffledChoicesIds", () => { expect(getShuffledChoicesIds(singleChoice, "exceptLast")).toEqual(["s1"]); }); }); +describe("getQuestionsFromSurvey", () => { + test("should return elements from blocks", () => { + const survey: TJsEnvironmentStateSurvey = { + ...baseMockSurvey, + blocks: [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "q1", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Question 1" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + { + id: "q2", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Question 2" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + }, + { + id: "block2", + name: "Block 2", + elements: [ + { + id: "q3", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Question 3" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + }, + ], + }; + + const questions = getQuestionsFromSurvey(survey); + expect(questions).toHaveLength(3); + expect(questions[0].id).toBe("q1"); + expect(questions[1].id).toBe("q2"); + expect(questions[2].id).toBe("q3"); + }); + + test("should return empty array when blocks is undefined", () => { + const surveyWithoutBlocks = { + ...baseMockSurvey, + }; + delete (surveyWithoutBlocks as Partial).blocks; + + expect(getQuestionsFromSurvey(surveyWithoutBlocks as TJsEnvironmentStateSurvey)).toEqual([]); + }); + + test("should return empty array when blocks is empty", () => { + const survey = { + ...baseMockSurvey, + blocks: [], + } as TJsEnvironmentStateSurvey; + + expect(getQuestionsFromSurvey(survey)).toEqual([]); + }); + + test("should handle blocks with no elements", () => { + const survey: TJsEnvironmentStateSurvey = { + ...baseMockSurvey, + blocks: [ + { id: "block1", name: "Block 1", elements: [] }, + { + id: "block2", + name: "Block 2", + elements: [ + { + id: "q1", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Q1" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + }, + ], + }; + + const questions = getQuestionsFromSurvey(survey); + expect(questions).toHaveLength(1); + expect(questions[0].id).toBe("q1"); + }); +}); + +describe("findBlockByElementId", () => { + const survey: TJsEnvironmentStateSurvey = { + ...baseMockSurvey, + blocks: [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "q1", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Question 1" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + { + id: "q2", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Question 2" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + }, + { + id: "block2", + name: "Block 2", + elements: [ + { + id: "q3", + type: TSurveyElementTypeEnum.OpenText, + headline: { en: "Question 3" }, + required: false, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + }, + ], + }; + + test("should find block containing the element", () => { + const block = findBlockByElementId(survey, "q1"); + expect(block).toBeDefined(); + expect(block?.id).toBe("block1"); + + const block2 = findBlockByElementId(survey, "q3"); + expect(block2).toBeDefined(); + expect(block2?.id).toBe("block2"); + }); + + test("should return undefined for non-existent element", () => { + const block = findBlockByElementId(survey, "nonexistent"); + expect(block).toBeUndefined(); + }); +}); diff --git a/packages/surveys/src/lib/utils.ts b/packages/surveys/src/lib/utils.ts index b4452b58ba..2b681ed565 100644 --- a/packages/surveys/src/lib/utils.ts +++ b/packages/surveys/src/lib/utils.ts @@ -2,13 +2,9 @@ import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-h import { type ApiErrorResponse } from "@formbricks/types/errors"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TAllowedFileExtension, mimeTypes } from "@formbricks/types/storage"; -import { - type TShuffleOption, - type TSurveyLogic, - type TSurveyLogicAction, - type TSurveyQuestion, - type TSurveyQuestionChoice, -} from "@formbricks/types/surveys/types"; +import { TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; +import { type TSurveyElement } from "@formbricks/types/surveys/elements"; +import { type TShuffleOption, type TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; import { ApiResponse, ApiSuccessResponse } from "@/types/api"; export const cn = (...classes: string[]) => { @@ -83,40 +79,49 @@ export const calculateElementIdx = ( currentQustionIdx: number, totalCards: number ): number => { - const currentQuestion = survey.questions[currentQustionIdx]; + const questions = getQuestionsFromSurvey(survey); + const currentQuestion = questions[currentQustionIdx]; const middleIdx = Math.floor(totalCards / 2); - const possibleNextQuestions = getPossibleNextQuestions(currentQuestion); + const possibleNextBlockIds = getPossibleNextBlocks(survey, currentQuestion); const endingCardIds = survey.endings.map((ending) => ending.id); + + // Convert block IDs to element IDs (get first element of each block) + const possibleNextQuestionIds = possibleNextBlockIds + .map((blockId) => getFirstElementIdInBlock(survey, blockId)) + .filter((id): id is string => id !== undefined); + const getLastQuestionIndex = () => { - const lastQuestion = survey.questions - .filter((q) => possibleNextQuestions.includes(q.id)) - .sort((a, b) => survey.questions.indexOf(a) - survey.questions.indexOf(b)) + const lastQuestion = questions + .filter((q) => possibleNextQuestionIds.includes(q.id)) + .sort((a, b) => questions.indexOf(a) - questions.indexOf(b)) .pop(); - return survey.questions.findIndex((e) => e.id === lastQuestion?.id); + return questions.findIndex((e) => e.id === lastQuestion?.id); }; let elementIdx = currentQustionIdx + 1; const lastprevQuestionIdx = getLastQuestionIndex(); if (lastprevQuestionIdx > 0) elementIdx = Math.min(middleIdx, lastprevQuestionIdx - 1); - if (possibleNextQuestions.some((id) => endingCardIds.includes(id))) elementIdx = middleIdx; + if (possibleNextBlockIds.some((id) => endingCardIds.includes(id))) elementIdx = middleIdx; return elementIdx; }; -const getPossibleNextQuestions = (question: TSurveyQuestion): string[] => { - if (!question.logic) return []; +const getPossibleNextBlocks = (survey: TJsEnvironmentStateSurvey, element: TSurveyElement): string[] => { + // In the blocks model, logic is stored at the block level + const parentBlock = findBlockByElementId(survey, element.id); + if (!parentBlock?.logic) return []; - const possibleDestinations: string[] = []; + const possibleBlockIds: string[] = []; - question.logic.forEach((logic: TSurveyLogic) => { - logic.actions.forEach((action: TSurveyLogicAction) => { - if (action.objective === "jumpToQuestion") { - possibleDestinations.push(action.target); + parentBlock.logic.forEach((logic: TSurveyBlockLogic) => { + logic.actions.forEach((action: TSurveyBlockLogicAction) => { + if (action.objective === "jumpToBlock") { + possibleBlockIds.push(action.target); } }); }); - return possibleDestinations; + return possibleBlockIds; }; export const isFulfilled = (val: PromiseSettledResult): val is PromiseFulfilledResult => { @@ -192,7 +197,8 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo } } - for (const question of survey.questions) { + const questions = getQuestionsFromSurvey(survey); + for (const question of questions) { const questionHeadline = question.headline[languageCode]; // the first non-empty question headline is the survey direction @@ -203,3 +209,38 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo return false; }; + +/** + * Derives a flat array of elements from the survey's blocks structure. + * @param survey The survey object with blocks + * @returns An array of TSurveyElement (pure elements without block-level properties) + */ +export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] => { + return survey.blocks?.flatMap((block) => block.elements) ?? []; +}; + +/** + * Finds the parent block that contains the specified element ID. + * Useful for accessing block-level properties like logic and button labels. + * @param survey The survey object with blocks + * @param elementId The ID of the element to find + * @returns The parent block or undefined if not found + */ +export const findBlockByElementId = (survey: TJsEnvironmentStateSurvey, elementId: string) => { + return survey.blocks?.find((b) => b.elements.some((e) => e.id === elementId)); +}; + +/** + * Converts a block ID to the first element ID in that block. + * Used for navigation when logic jumps to a block. + * @param survey The survey object with blocks + * @param blockId The block ID to convert + * @returns The first element ID in the block, or undefined if block not found or empty + */ +export const getFirstElementIdInBlock = ( + survey: TJsEnvironmentStateSurvey, + blockId: string +): string | undefined => { + const block = survey.blocks?.find((b) => b.id === blockId); + return block?.elements[0]?.id; +}; diff --git a/packages/types/js.ts b/packages/types/js.ts index ee2c2f61ef..7761e0d17d 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -13,6 +13,7 @@ export const ZJsEnvironmentStateSurvey = ZSurvey.innerType() name: true, welcomeCard: true, questions: true, + blocks: true, variables: true, type: true, showLanguageSwitch: true, diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 43eec76c1f..b133aadccc 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -2829,13 +2829,12 @@ const isInvalidOperatorsForElementType = ( } break; case TSurveyElementTypeEnum.MultipleChoiceSingle: - if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + if (!["equals", "doesNotEqual", "equalsOneOf", "isSubmitted", "isSkipped"].includes(operator)) { isInvalidOperator = true; } break; case TSurveyElementTypeEnum.MultipleChoiceMulti: case TSurveyElementTypeEnum.PictureSelection: - case TSurveyElementTypeEnum.Ranking: if ( ![ "equals", @@ -2851,6 +2850,11 @@ const isInvalidOperatorsForElementType = ( isInvalidOperator = true; } break; + case TSurveyElementTypeEnum.Ranking: + if (!["isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; case TSurveyElementTypeEnum.NPS: case TSurveyElementTypeEnum.Rating: if ( @@ -2889,7 +2893,7 @@ const isInvalidOperatorsForElementType = ( } break; case TSurveyElementTypeEnum.Date: - if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + if (!["equals", "doesNotEqual", "isBefore", "isAfter", "isSubmitted", "isSkipped"].includes(operator)) { isInvalidOperator = true; } break; @@ -2898,40 +2902,20 @@ const isInvalidOperatorsForElementType = ( ![ "isPartiallySubmitted", "isCompletelySubmitted", + "isSkipped", + "isEmpty", + "isNotEmpty", + "isAnyOf", "equals", "doesNotEqual", - "isSubmitted", - "isSkipped", ].includes(operator) ) { isInvalidOperator = true; } break; case TSurveyElementTypeEnum.Address: - if ( - ![ - "isPartiallySubmitted", - "isCompletelySubmitted", - "isEmpty", - "isNotEmpty", - "isSubmitted", - "isSkipped", - ].includes(operator) - ) { - isInvalidOperator = true; - } - break; case TSurveyElementTypeEnum.ContactInfo: - if ( - ![ - "isPartiallySubmitted", - "isCompletelySubmitted", - "isEmpty", - "isNotEmpty", - "isSubmitted", - "isSkipped", - ].includes(operator) - ) { + if (!["isSubmitted", "isSkipped"].includes(operator)) { isInvalidOperator = true; } break; From 8c05154a86cd0f3f3af091ec8d37c63e914a54da Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 7 Nov 2025 12:30:25 +0530 Subject: [PATCH 2/3] fixes feedback --- .../survey/components/question-form-input/index.tsx | 8 ++++---- apps/web/modules/ui/components/file-input/index.tsx | 12 +++++++----- .../surveys/src/components/general/progress-bar.tsx | 4 ++-- .../components/general/response-error-component.tsx | 2 +- packages/surveys/src/components/general/survey.tsx | 9 +++++++-- packages/surveys/src/lib/recall.ts | 6 +++--- packages/surveys/src/lib/utils.ts | 12 +++++------- 7 files changed, 29 insertions(+), 24 deletions(-) diff --git a/apps/web/modules/survey/components/question-form-input/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx index f8e6c9b00f..f7969b0279 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -335,8 +335,8 @@ export const QuestionFormInput = ({ if (url) { const update = fileType === "video" - ? { videoUrl: url[0], imageUrl: "" } - : { imageUrl: url[0], videoUrl: "" }; + ? { videoUrl: url[0], imageUrl: undefined } + : { imageUrl: url[0], videoUrl: undefined }; if ((isWelcomeCard || isEndingCard) && updateSurvey) { updateSurvey(update); } else if (updateQuestion) { @@ -469,8 +469,8 @@ export const QuestionFormInput = ({ if (url) { const update = fileType === "video" - ? { videoUrl: url[0], imageUrl: "" } - : { imageUrl: url[0], videoUrl: "" }; + ? { videoUrl: url[0], imageUrl: undefined } + : { imageUrl: url[0], videoUrl: undefined }; if (isEndingCard && updateSurvey) { updateSurvey(update); } else if (updateQuestion) { diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx index e3dea42f1c..e6d19bff32 100644 --- a/apps/web/modules/ui/components/file-input/index.tsx +++ b/apps/web/modules/ui/components/file-input/index.tsx @@ -200,21 +200,23 @@ export const FileInput = ({ setSelectedFiles(getSelectedFiles()); }, [fileUrl]); - // useEffect to handle the state when switching between 'image' and 'video' tabs. useEffect(() => { if (activeTab === "image" && typeof imageUrlTemp === "string") { // Temporarily store the current video URL before switching tabs. setVideoUrlTemp(videoUrl ?? ""); - // Re-upload the image using the temporary image URL. - onFileUpload([imageUrlTemp], "image"); + if (imageUrlTemp) { + onFileUpload([imageUrlTemp], "image"); + } } else if (activeTab === "video") { // Temporarily store the current image URL before switching tabs. setImageUrlTemp(fileUrl ?? ""); - // Re-upload the video using the temporary video URL. - onFileUpload([videoUrlTemp], "video"); + if (videoUrlTemp) { + onFileUpload([videoUrlTemp], "video"); + } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when activeTab changes to avoid infinite loops }, [activeTab]); return ( diff --git a/packages/surveys/src/components/general/progress-bar.tsx b/packages/surveys/src/components/general/progress-bar.tsx index 662d7f6dff..b8c14ffd69 100644 --- a/packages/surveys/src/components/general/progress-bar.tsx +++ b/packages/surveys/src/components/general/progress-bar.tsx @@ -19,7 +19,7 @@ export function ProgressBar({ survey, questionId }: ProgressBarProps) { const calculateProgress = useCallback( (index: number) => { - let totalCards = questions.length; + let totalCards = survey.blocks.length; if (endingCardIds.length > 0) totalCards += 1; let idx = index; @@ -28,7 +28,7 @@ export function ProgressBar({ survey, questionId }: ProgressBarProps) { const elementIdx = calculateElementIdx(survey, idx, totalCards); return elementIdx / totalCards; }, - [survey, questions.length, endingCardIds.length] + [survey, endingCardIds.length] ); const progressArray = useMemo(() => { diff --git a/packages/surveys/src/components/general/response-error-component.tsx b/packages/surveys/src/components/general/response-error-component.tsx index 28aee5ea82..8e2a34cba3 100644 --- a/packages/surveys/src/components/general/response-error-component.tsx +++ b/packages/surveys/src/components/general/response-error-component.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { type TResponseData } from "@formbricks/types/responses"; -import { TSurveyElement } from "@formbricks/types/surveys/elements"; +import { type TSurveyElement } from "@formbricks/types/surveys/elements"; import { SubmitButton } from "@/components/buttons/submit-button"; import { processResponseData } from "@/lib/response"; diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index 628ae4b117..7fa6b8e46d 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -27,8 +27,13 @@ import { evaluateLogic, performActions } from "@/lib/logic"; import { parseRecallInformation } from "@/lib/recall"; import { ResponseQueue } from "@/lib/response-queue"; import { SurveyState } from "@/lib/survey-state"; -import { findBlockByElementId, getFirstElementIdInBlock, getQuestionsFromSurvey } from "@/lib/utils"; -import { cn, getDefaultLanguageCode } from "@/lib/utils"; +import { + cn, + findBlockByElementId, + getDefaultLanguageCode, + getFirstElementIdInBlock, + getQuestionsFromSurvey, +} from "@/lib/utils"; import { TResponseErrorCodesEnum } from "@/types/response-error-codes"; interface VariableStackEntry { diff --git a/packages/surveys/src/lib/recall.ts b/packages/surveys/src/lib/recall.ts index 1be78d67c9..a3358e6278 100644 --- a/packages/surveys/src/lib/recall.ts +++ b/packages/surveys/src/lib/recall.ts @@ -68,12 +68,12 @@ export const replaceRecallInfo = ( return modifiedText; }; -export const parseRecallInformation = ( - question: T, +export const parseRecallInformation = ( + question: TSurveyElement, languageCode: string, responseData: TResponseData, variables: TResponseVariables -): T => { +): TSurveyElement => { const modifiedQuestion = JSON.parse(JSON.stringify(question)); if (question.headline[languageCode].includes("recall:")) { modifiedQuestion.headline[languageCode] = replaceRecallInfo( diff --git a/packages/surveys/src/lib/utils.ts b/packages/surveys/src/lib/utils.ts index 2b681ed565..c51f092cf0 100644 --- a/packages/surveys/src/lib/utils.ts +++ b/packages/surveys/src/lib/utils.ts @@ -215,9 +215,8 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo * @param survey The survey object with blocks * @returns An array of TSurveyElement (pure elements without block-level properties) */ -export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] => { - return survey.blocks?.flatMap((block) => block.elements) ?? []; -}; +export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] => + survey.blocks.flatMap((block) => block.elements); /** * Finds the parent block that contains the specified element ID. @@ -226,9 +225,8 @@ export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurv * @param elementId The ID of the element to find * @returns The parent block or undefined if not found */ -export const findBlockByElementId = (survey: TJsEnvironmentStateSurvey, elementId: string) => { - return survey.blocks?.find((b) => b.elements.some((e) => e.id === elementId)); -}; +export const findBlockByElementId = (survey: TJsEnvironmentStateSurvey, elementId: string) => + survey.blocks.find((b) => b.elements.some((e) => e.id === elementId)); /** * Converts a block ID to the first element ID in that block. @@ -241,6 +239,6 @@ export const getFirstElementIdInBlock = ( survey: TJsEnvironmentStateSurvey, blockId: string ): string | undefined => { - const block = survey.blocks?.find((b) => b.id === blockId); + const block = survey.blocks.find((b) => b.id === blockId); return block?.elements[0]?.id; }; From b1da63e47dcb7889b66160cd9f240deb5fce8531 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 7 Nov 2025 14:22:13 +0530 Subject: [PATCH 3/3] fixes description issue --- .../components/localized-editor.tsx | 11 ++- .../components/question-form-input/index.tsx | 86 ++++--------------- 2 files changed, 27 insertions(+), 70 deletions(-) diff --git a/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx b/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx index bea4f9bf71..0c12b1ef53 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx @@ -30,6 +30,7 @@ interface LocalizedEditorProps { isCard?: boolean; // Flag to indicate if this is a welcome/ending card autoFocus?: boolean; isExternalUrlsAllowed?: boolean; + suppressUpdates?: () => boolean; // Function to check if updates should be suppressed (e.g., during deletion) } const checkIfValueIsIncomplete = ( @@ -62,6 +63,7 @@ export function LocalizedEditor({ isCard, autoFocus, isExternalUrlsAllowed, + suppressUpdates, }: Readonly) { // Derive questions from blocks for migrated surveys const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]); @@ -96,6 +98,12 @@ export function LocalizedEditor({ key={`${questionId}-${id}-${selectedLanguageCode}`} setFirstRender={setFirstRender} setText={(v: string) => { + // Early exit if updates are suppressed (e.g., during deletion) + // This prevents race conditions where setText fires with stale props before React updates state + if (suppressUpdates?.()) { + return; + } + let sanitizedContent = v; if (!isExternalUrlsAllowed) { sanitizedContent = v.replaceAll(/]*>(.*?)<\/a>/gi, "$1"); @@ -131,7 +139,8 @@ export function LocalizedEditor({ return; } - if (currentQuestion && currentQuestion[id] !== undefined) { + // Check if the field exists on the question (not just if it's not undefined) + if (currentQuestion && id in currentQuestion && currentQuestion[id] !== undefined) { const translatedContent = { ...value, [selectedLanguageCode]: sanitizedContent, diff --git a/apps/web/modules/survey/components/question-form-input/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx index f7969b0279..23b1dc80bd 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -290,6 +290,7 @@ export const QuestionFormInput = ({ const [animationParent] = useAutoAnimate(); const [internalFirstRender, setInternalFirstRender] = useState(true); + const suppressEditorUpdatesRef = useRef(false); // Use external firstRender state if provided, otherwise use internal state const firstRender = externalFirstRender ?? internalFirstRender; @@ -371,6 +372,7 @@ export const QuestionFormInput = ({ isCard={isWelcomeCard || isEndingCard} autoFocus={autoFocus} isExternalUrlsAllowed={isExternalUrlsAllowed} + suppressUpdates={() => suppressEditorUpdatesRef.current} />
@@ -399,6 +401,12 @@ export const QuestionFormInput = ({ onClick={(e) => { e.preventDefault(); + // Suppress Editor updates BEFORE calling updateQuestion to prevent race condition + // Use ref for immediate synchronous access + if (id === "subheader") { + suppressEditorUpdatesRef.current = true; + } + if (updateSurvey) { updateSurvey({ subheader: undefined }); } @@ -406,6 +414,13 @@ export const QuestionFormInput = ({ if (updateQuestion) { updateQuestion(questionIdx, { subheader: undefined }); } + + // Re-enable updates after a short delay to allow state to update + if (id === "subheader") { + setTimeout(() => { + suppressEditorUpdatesRef.current = false; + }, 100); + } }}> @@ -449,7 +464,7 @@ export const QuestionFormInput = ({ onAddFallback={() => { inputRef.current?.focus(); }} - isRecallAllowed={id === "headline" || id === "subheader"} + isRecallAllowed={false} usedLanguageCode={usedLanguageCode} render={({ value, @@ -460,32 +475,6 @@ export const QuestionFormInput = ({ }) => { return (
- {showImageUploader && id === "headline" && ( - { - if (url) { - const update = - fileType === "video" - ? { videoUrl: url[0], imageUrl: undefined } - : { imageUrl: url[0], videoUrl: undefined }; - if (isEndingCard && updateSurvey) { - updateSurvey(update); - } else if (updateQuestion) { - updateQuestion(questionIdx, update); - } - } - }} - fileUrl={getFileUrl()} - videoUrl={getVideoUrl()} - isVideoAllowed={true} - maxSizeInMB={5} - isStorageConfigured={isStorageConfigured} - /> - )} -
{languageIndicator} @@ -532,52 +521,11 @@ export const QuestionFormInput = ({ isTranslationIncomplete } autoComplete={isRecallSelectVisible ? "off" : "on"} - autoFocus={id === "headline"} + autoFocus={false} onKeyDown={handleKeyDown} /> {recallComponents}
- - <> - {id === "headline" && !isWelcomeCard && ( - - - - )} - {renderRemoveDescriptionButton() ? ( - - - - ) : null} -
);