mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
feat: Moving surveys package logic to blocks (#6785)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const getSurveyQuestions = reactCache(async (surveyId: string) => {
|
||||
select: {
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: "" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ZJsEnvironmentStateSurvey = ZSurvey.innerType()
|
||||
name: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user