feat: Moving surveys package logic to blocks (#6785)

This commit is contained in:
Anshuman Pandey
2025-11-10 09:47:46 +05:30
committed by GitHub
39 changed files with 793 additions and 485 deletions

View File

@@ -92,6 +92,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
welcomeCard: true,
name: true,
questions: true,
blocks: true,
variables: true,
type: true,
showLanguageSwitch: true,

View File

@@ -11,6 +11,7 @@ export const getSurveyQuestions = reactCache(async (surveyId: string) => {
select: {
environmentId: true,
questions: true,
blocks: true,
},
});

View File

@@ -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<LocalizedEditorProps>) {
// 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[^>]*>(.*?)<\/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,

View File

@@ -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;
@@ -335,8 +336,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) {
@@ -371,6 +372,7 @@ export const QuestionFormInput = ({
isCard={isWelcomeCard || isEndingCard}
autoFocus={autoFocus}
isExternalUrlsAllowed={isExternalUrlsAllowed}
suppressUpdates={() => suppressEditorUpdatesRef.current}
/>
</div>
@@ -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);
}
}}>
<TrashIcon />
</Button>
@@ -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 (
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={localSurvey.environmentId}
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url) {
const update =
fileType === "video"
? { videoUrl: url[0], imageUrl: "" }
: { imageUrl: url[0], videoUrl: "" };
if (isEndingCard && updateSurvey) {
updateSurvey(update);
} else if (updateQuestion) {
updateQuestion(questionIdx, update);
}
}
}}
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}
<div className="flex w-full items-center space-x-2">
<div className="group relative w-full">
{languageIndicator}
@@ -532,52 +521,11 @@ export const QuestionFormInput = ({
isTranslationIncomplete
}
autoComplete={isRecallSelectVisible ? "off" : "on"}
autoFocus={id === "headline"}
autoFocus={false}
onKeyDown={handleKeyDown}
/>
{recallComponents}
</div>
<>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<Button
variant="secondary"
size="icon"
aria-label="Toggle image uploader"
data-testid="toggle-image-uploader-button"
className="ml-2"
onClick={(e) => {
e.preventDefault();
setShowImageUploader((prev) => !prev);
}}>
<ImagePlusIcon />
</Button>
</TooltipRenderer>
)}
{renderRemoveDescriptionButton() ? (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
<Button
variant="secondary"
size="icon"
aria-label="Remove description"
className="ml-2"
onClick={(e) => {
e.preventDefault();
if (updateSurvey) {
updateSurvey({ subheader: undefined });
}
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
}}>
<TrashIcon />
</Button>
</TooltipRenderer>
) : null}
</>
</div>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -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 = survey.blocks.length;
if (endingCardIds.length > 0) totalCards += 1;
let idx = index;
@@ -31,8 +32,8 @@ export function ProgressBar({ survey, questionId }: ProgressBarProps) {
);
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") {

View File

@@ -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<void>;
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 ? (
<OpenTextQuestion
key={question.id}
question={question}
@@ -108,8 +112,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? (
) : question.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? (
<MultipleChoiceSingleQuestion
key={question.id}
question={question}
@@ -127,8 +133,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
) : question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
key={question.id}
question={question}
@@ -146,8 +154,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
) : question.type === TSurveyElementTypeEnum.NPS ? (
<NPSQuestion
key={question.id}
question={question}
@@ -165,8 +175,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
) : question.type === TSurveyElementTypeEnum.CTA ? (
<CTAQuestion
key={question.id}
question={question}
@@ -184,8 +196,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
onOpenExternalURL={onOpenExternalURL}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
) : question.type === TSurveyElementTypeEnum.Rating ? (
<RatingQuestion
key={question.id}
question={question}
@@ -203,8 +217,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
) : question.type === TSurveyElementTypeEnum.Consent ? (
<ConsentQuestion
key={question.id}
question={question}
@@ -222,8 +238,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.Date ? (
) : question.type === TSurveyElementTypeEnum.Date ? (
<DateQuestion
key={question.id}
question={question}
@@ -241,7 +259,7 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
fullSizeCards={fullSizeCards}
/>
) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? (
) : question.type === TSurveyElementTypeEnum.PictureSelection ? (
<PictureSelectionQuestion
key={question.id}
question={question}
@@ -259,8 +277,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.FileUpload ? (
) : question.type === TSurveyElementTypeEnum.FileUpload ? (
<FileUploadQuestion
key={question.id}
surveyId={surveyId}
@@ -280,7 +300,7 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
fullSizeCards={fullSizeCards}
/>
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
) : question.type === TSurveyElementTypeEnum.Cal ? (
<CalQuestion
key={question.id}
question={question}
@@ -297,8 +317,10 @@ export function QuestionConditional({
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.Matrix ? (
) : question.type === TSurveyElementTypeEnum.Matrix ? (
<MatrixQuestion
question={question}
value={typeof value === "object" && !Array.isArray(value) ? value : {}}
@@ -313,8 +335,10 @@ export function QuestionConditional({
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.Address ? (
) : question.type === TSurveyElementTypeEnum.Address ? (
<AddressQuestion
question={question}
value={Array.isArray(value) ? value : undefined}
@@ -331,8 +355,10 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
) : question.type === TSurveyElementTypeEnum.Ranking ? (
<RankingQuestion
question={question}
value={Array.isArray(value) ? getResponseValueForRankingQuestion(value, question.choices) : []}
@@ -349,7 +375,7 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
fullSizeCards={fullSizeCards}
/>
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
) : question.type === TSurveyElementTypeEnum.ContactInfo ? (
<ContactInfoQuestion
question={question}
value={Array.isArray(value) ? value : undefined}
@@ -366,6 +392,8 @@ export function QuestionConditional({
isBackButtonHidden={isBackButtonHidden}
dir={dir}
fullSizeCards={fullSizeCards}
buttonLabel={buttonLabel}
backButtonLabel={backButtonLabel}
/>
) : null;
}

View File

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

View File

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

View File

@@ -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,17 @@ import { evaluateLogic, performActions } from "@/lib/logic";
import { parseRecallInformation } from "@/lib/recall";
import { ResponseQueue } from "@/lib/response-queue";
import { SurveyState } from "@/lib/survey-state";
import { cn, getDefaultLanguageCode } from "@/lib/utils";
import {
cn,
findBlockByElementId,
getDefaultLanguageCode,
getFirstElementIdInBlock,
getQuestionsFromSurvey,
} from "@/lib/utils";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
interface VariableStackEntry {
questionId: TSurveyQuestionId;
questionId: string;
variables: TResponseVariables;
}
@@ -142,12 +146,15 @@ export function Survey({
return null;
}, [appUrl, environmentId, getSetIsError, getSetIsResponseSendingFinished, surveyState]);
const questions = useMemo(() => getQuestionsFromSurvey(localSurvey), [localSurvey]);
const originalQuestionRequiredStates = useMemo(() => {
return survey.questions.reduce<Record<string, boolean>>((acc, question) => {
return questions.reduce<Record<string, boolean>>((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<Record<string, string[]>>({});
@@ -174,7 +181,7 @@ export function Survey({
} else if (localSurvey.welcomeCard.enabled) {
return "start";
}
return localSurvey.questions[0]?.id;
return questions[0]?.id;
});
const [errorType, setErrorType] = useState<TResponseErrorCodesEnum | undefined>(undefined);
const [showError, setShowError] = useState(false);
@@ -196,8 +203,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<HTMLDivElement | null>(null);
const showProgressBar = !styling.hideProgressBar;
@@ -345,15 +352,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 +373,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 +392,7 @@ export function Survey({
}
};
const pushVariableState = (currentQuestionId: TSurveyQuestionId) => {
const pushVariableState = (currentQuestionId: string) => {
setVariableStack((prevStack) => [
...prevStack,
{ questionId: currentQuestionId, variables: { ...currentVariables } },
@@ -399,8 +412,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 +426,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 +441,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 +456,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 +468,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 +608,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 +645,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 +655,7 @@ export function Survey({
};
const getQuestionPrefillData = (
prefillQuestionId: TSurveyQuestionId,
prefillQuestionId: string,
offset: number
): TResponseDataValue | undefined => {
if (offset === 0 && prefillResponseData) {
@@ -657,7 +681,7 @@ export function Survey({
return (
<ResponseErrorComponent
responseData={responseData}
questions={localSurvey.questions}
questions={questions}
onRetry={retryResponse}
/>
);
@@ -697,7 +721,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 +744,12 @@ export function Survey({
);
}
} else {
const question = localSurvey.questions[questionIdx];
const question = questions[questionIdx];
return (
Boolean(question) && (
<QuestionConditional
key={question.id}
survey={localSurvey}
surveyId={localSurvey.id}
question={parseRecallInformation(question, selectedLanguage, responseData, currentVariables)}
value={responseData[question.id]}
@@ -734,10 +759,10 @@ export function Survey({
ttc={ttc}
setTtc={setTtc}
onFileUpload={onFileUpload}
isFirstQuestion={question.id === localSurvey.questions[0]?.id}
isFirstQuestion={question.id === questions[0]?.id}
skipPrefilled={skipPrefilled}
prefilledQuestionValue={getQuestionPrefillData(question.id, offset)}
isLastQuestion={question.id === localSurvey.questions[localSurvey.questions.length - 1].id}
isLastQuestion={question.id === questions[questions.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={questionId}

View File

@@ -7,7 +7,7 @@ import { SubmitButton } from "@/components/buttons/submit-button";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx } from "@/lib/utils";
import { calculateElementIdx, getQuestionsFromSurvey } from "@/lib/utils";
import { Headline } from "./headline";
import { Subheader } from "./subheader";
@@ -84,13 +84,14 @@ export function WelcomeCard({
const { t } = useTranslation();
const calculateTimeToComplete = () => {
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 });

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={
backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined
}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);

View File

@@ -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({
</div>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
@@ -111,7 +116,7 @@ export function CalQuestion({
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
onBack();
}}

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);

View File

@@ -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<void>;
fullSizeCards: boolean;
@@ -30,6 +33,8 @@ interface CTAQuestionProps {
export function CTAQuestion({
question,
buttonLabel,
backButtonLabel,
onSubmit,
onChange,
onBack,
@@ -68,7 +73,7 @@ export function CTAQuestion({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
focus={isCurrent ? autoFocusEnabled : false}
tabIndex={isCurrent ? 0 : -1}
@@ -106,7 +111,9 @@ export function CTAQuestion({
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={
backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined
}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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({
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
isLastQuestion={isLastQuestion}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
onBack();
}}

View File

@@ -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<string, string>;
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<string, string>, row: TSurveyMatrixQuestionChoice) => {
: question.rows.reduce((obj: Record<string, string>, row: TSurveyMatrixElementChoice) => {
obj[getLocalizedValue(row.label, languageCode)] = ""; // Initialize each row key with an empty string
return obj;
}, {});
@@ -203,13 +204,13 @@ export function MatrixQuestion({
</div>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={handleBackButtonClick}
tabIndex={isCurrent ? 0 : -1}
/>

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);

View File

@@ -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({
) : (
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
)}
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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({
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
tabIndex={isCurrent ? 0 : -1}
onClick={handleBack}
/>

View File

@@ -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({
) : (
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
buttonLabel={buttonLabel ? getLocalizedValue(buttonLabel, languageCode) : undefined}
isLastQuestion={isLastQuestion}
/>
)}
@@ -281,7 +286,7 @@ export function RatingQuestion({
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
backButtonLabel={backButtonLabel ? getLocalizedValue(backButtonLabel, languageCode) : undefined}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);

View File

@@ -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<number>(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 (
<StackedCard

View File

@@ -2,8 +2,9 @@ import { describe, expect, test, vi } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import { TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
import { TSurveyVariable } from "@formbricks/types/surveys/types";
import { evaluateLogic, isConditionGroup, performActions } from "./logic";
// Mock the imported function
@@ -27,93 +28,100 @@ describe("Survey Logic", () => {
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" } },
],
},
],
},
],

View File

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

View File

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

View File

@@ -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";
@@ -69,11 +69,11 @@ export const replaceRecallInfo = (
};
export const parseRecallInformation = (
question: TSurveyQuestion,
question: TSurveyElement,
languageCode: string,
responseData: TResponseData,
variables: TResponseVariables
) => {
): TSurveyElement => {
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

View File

@@ -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<typeof vi.fn>;
let currentTime = 0;
let initialProps: {
questionId: TSurveyQuestionId;
questionId: string;
ttc: TResponseTtc;
setTtc: ReturnType<typeof vi.fn>;
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,

View File

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

View File

@@ -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<TJsEnvironmentStateSurvey> = {
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<TJsEnvironmentStateSurvey>).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();
});
});

View File

@@ -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 = <T>(val: PromiseSettledResult<T>): val is PromiseFulfilledResult<T> => {
@@ -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,36 @@ 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[] =>
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) =>
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;
};

View File

@@ -13,6 +13,7 @@ export const ZJsEnvironmentStateSurvey = ZSurvey.innerType()
name: true,
welcomeCard: true,
questions: true,
blocks: true,
variables: true,
type: true,
showLanguageSwitch: true,

View File

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