mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Merge branch 'fix/cta-logic-templates' into fix/questions-backwards-compat
This commit is contained in:
@@ -133,7 +133,7 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
id: reusableElementIds[1],
|
||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||
@@ -344,7 +344,7 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
id: reusableElementIds[1],
|
||||
subheader: t("templates.smileys_survey_question_2_html"),
|
||||
headline: t("templates.smileys_survey_question_2_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TSurveyAddressElement,
|
||||
TSurveyContactInfoElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
TSurveyElementSummaryRanking,
|
||||
TSurveyElementSummaryRating,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
@@ -323,7 +323,7 @@ const checkForI18n = (
|
||||
// Return the localized value of the choice fo multiSelect single question
|
||||
if (question && "choices" in question) {
|
||||
const choice = question.choices?.find(
|
||||
(choice: TSurveyQuestionChoice) => choice.label?.[languageCode] === responseData[id]
|
||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
||||
);
|
||||
return choice && "label" in choice
|
||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
||||
|
||||
@@ -562,7 +562,7 @@ const churnSurvey = (t: TFunction): TTemplate => {
|
||||
id: reusableElementIds[2],
|
||||
subheader: t("templates.churn_survey_question_3_html"),
|
||||
headline: t("templates.churn_survey_question_3_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.churn_survey_question_3_button_label"),
|
||||
@@ -593,7 +593,7 @@ const churnSurvey = (t: TFunction): TTemplate => {
|
||||
id: reusableElementIds[4],
|
||||
subheader: t("templates.churn_survey_question_5_html"),
|
||||
headline: t("templates.churn_survey_question_5_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
buttonUrl: "mailto:ceo@company.com",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.churn_survey_question_5_button_label"),
|
||||
@@ -988,7 +988,7 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
|
||||
id: reusableElementIds[3],
|
||||
subheader: t("templates.improve_trial_conversion_question_4_html"),
|
||||
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.improve_trial_conversion_question_4_button_label"),
|
||||
@@ -1106,7 +1106,7 @@ const reviewPrompt = (t: TFunction): TTemplate => {
|
||||
id: reusableElementIds[1],
|
||||
subheader: t("templates.review_prompt_question_2_html"),
|
||||
headline: t("templates.review_prompt_question_2_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
}),
|
||||
@@ -3943,7 +3943,7 @@ const evaluateAProductIdea = (t: TFunction): TTemplate => {
|
||||
id: reusableElementIds[0],
|
||||
subheader: t("templates.evaluate_a_product_idea_question_1_html"),
|
||||
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
||||
@@ -3991,7 +3991,7 @@ const evaluateAProductIdea = (t: TFunction): TTemplate => {
|
||||
id: reusableElementIds[3],
|
||||
subheader: t("templates.evaluate_a_product_idea_question_4_html"),
|
||||
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
|
||||
required: true,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
||||
|
||||
@@ -1386,8 +1386,8 @@ checksums:
|
||||
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
|
||||
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
|
||||
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
|
||||
environments/surveys/edit/next_block: 53eaa5b1c9333455ab1e99bedd222ba2
|
||||
environments/surveys/edit/next_button_label: e23522dd38f3eabeeccd3f48f32b73a8
|
||||
environments/surveys/edit/next_question: 2e0f1ea264fb4bfcb8378b2b0cf7c18f
|
||||
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
|
||||
environments/surveys/edit/no_images_found_for: 90f10f4611ed7b115a49595409b66ebe
|
||||
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyCalQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyDateQuestion,
|
||||
@@ -173,20 +173,17 @@ export const mockNpsQuestion: TSurveyNPSQuestion = {
|
||||
isColorCodingEnabled: false,
|
||||
};
|
||||
|
||||
export const mockCtaQuestion: TSurveyCTAQuestion = {
|
||||
export const mockCtaQuestion: TSurveyCTAElement = {
|
||||
required: true,
|
||||
headline: {
|
||||
default: "You are one of our power users!",
|
||||
},
|
||||
buttonLabel: {
|
||||
ctaButtonLabel: {
|
||||
default: "Book interview",
|
||||
},
|
||||
buttonExternal: false,
|
||||
dismissButtonLabel: {
|
||||
default: "Skip",
|
||||
},
|
||||
buttonExternal: true,
|
||||
id: "gwn15urom4ffnhfimwbz3vgc",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
@@ -445,15 +442,13 @@ export const mockLegacyNpsQuestion = {
|
||||
export const mockTranslatedCtaQuestion = {
|
||||
...mockCtaQuestion,
|
||||
headline: { default: "You are one of our power users!", de: "" },
|
||||
buttonLabel: { default: "Book interview", de: "" },
|
||||
dismissButtonLabel: { default: "Skip", de: "" },
|
||||
ctaButtonLabel: { default: "Book interview", de: "" },
|
||||
};
|
||||
|
||||
export const mockLegacyCtaQuestion = {
|
||||
...mockCtaQuestion,
|
||||
headline: "You are one of our power users!",
|
||||
buttonLabel: "Book interview",
|
||||
dismissButtonLabel: "Skip",
|
||||
ctaButtonLabel: "Book interview",
|
||||
};
|
||||
|
||||
export const mockTranslatedConsentQuestion = {
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "Frage in Block verschieben",
|
||||
"multiply": "Multiplizieren *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
|
||||
"next_block": "Nächster Block",
|
||||
"next_button_label": "Beschriftung der Schaltfläche \"Weiter\"",
|
||||
"next_question": "Nächste Frage",
|
||||
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.",
|
||||
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "Move question to block",
|
||||
"multiply": "Multiply *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
|
||||
"next_block": "Next block",
|
||||
"next_button_label": "\"Next\" button label",
|
||||
"next_question": "Next question",
|
||||
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
|
||||
"no_images_found_for": "No images found for ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "Déplacer la question vers le bloc",
|
||||
"multiply": "Multiplier *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
|
||||
"next_block": "Bloc suivant",
|
||||
"next_button_label": "Libellé du bouton « Suivant »",
|
||||
"next_question": "Question suivante",
|
||||
"no_hidden_fields_yet_add_first_one_below": "Aucun champ caché pour le moment. Ajoutez le premier ci-dessous.",
|
||||
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "質問をブロックに移動",
|
||||
"multiply": "乗算 *",
|
||||
"needed_for_self_hosted_cal_com_instance": "セルフホストのCal.comインスタンスに必要",
|
||||
"next_block": "次のブロック",
|
||||
"next_button_label": "「次へ」ボタンのラベル",
|
||||
"next_question": "次の質問",
|
||||
"no_hidden_fields_yet_add_first_one_below": "まだ非表示フィールドがありません。以下で最初のものを追加してください。",
|
||||
"no_images_found_for": "''{query}'' の画像が見つかりません",
|
||||
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "Mover pergunta para o bloco",
|
||||
"multiply": "Multiplicar *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
||||
"next_block": "Próximo bloco",
|
||||
"next_button_label": "Próximo",
|
||||
"next_question": "próxima pergunta",
|
||||
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
|
||||
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "Mover pergunta para o bloco",
|
||||
"multiply": "Multiplicar *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
||||
"next_block": "Bloco seguinte",
|
||||
"next_button_label": "Rótulo do botão \"Seguinte\"",
|
||||
"next_question": "Próxima pergunta",
|
||||
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
|
||||
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "Mută întrebarea în bloc",
|
||||
"multiply": "Multiplicare",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
|
||||
"next_block": "Blocul următor",
|
||||
"next_button_label": "Etichetă buton \"Următorul\"",
|
||||
"next_question": "Întrebarea următoare",
|
||||
"no_hidden_fields_yet_add_first_one_below": "Nu există încă câmpuri ascunse. Adăugați primul mai jos.",
|
||||
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "将问题移动到区块",
|
||||
"multiply": "乘 *",
|
||||
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
|
||||
"next_block": "下一块",
|
||||
"next_button_label": "\"下一步\" 按钮标签",
|
||||
"next_question": "下一个问题",
|
||||
"no_hidden_fields_yet_add_first_one_below": "还没有隐藏字段。 在下面添加第一个。",
|
||||
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
|
||||
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
|
||||
|
||||
@@ -1471,8 +1471,8 @@
|
||||
"move_question_to_block": "將問題移至區塊",
|
||||
"multiply": "乘 *",
|
||||
"needed_for_self_hosted_cal_com_instance": "自行託管 Cal.com 執行個體時需要",
|
||||
"next_block": "下一個區塊",
|
||||
"next_button_label": "「下一步」按鈕標籤",
|
||||
"next_question": "下一個問題",
|
||||
"no_hidden_fields_yet_add_first_one_below": "尚無隱藏欄位。在下方新增第一個隱藏欄位。",
|
||||
"no_images_found_for": "找不到「'{'query'}'」的圖片",
|
||||
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
|
||||
|
||||
@@ -11,19 +11,18 @@ import {
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import { TFunction } from "i18next";
|
||||
import { CalendarDaysIcon, UploadIcon } from "lucide-react";
|
||||
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
|
||||
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
|
||||
import { QuestionHeader } from "./email-question-header";
|
||||
|
||||
@@ -85,8 +84,6 @@ export async function PreviewEmailTemplate({
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const firstQuestion = questions[0];
|
||||
|
||||
const { block } = findElementLocation(survey, firstQuestion.id);
|
||||
|
||||
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
|
||||
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
|
||||
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
|
||||
@@ -178,30 +175,27 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
const ctaElement = firstQuestion as TSurveyCTAElement;
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
{ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && (
|
||||
<Container className="mx-0 mt-4 flex max-w-none items-center justify-end">
|
||||
<EmailButton
|
||||
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}>
|
||||
{getLocalizedValue(firstQuestion.dismissButtonLabel, defaultLanguageCode) || "Skip"}
|
||||
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base font-medium leading-4 no-underline shadow-none"
|
||||
href={ctaElement.buttonUrl}>
|
||||
<Text className="inline">
|
||||
{getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "}
|
||||
</Text>
|
||||
<ExternalLinkIcon className="ml-2 inline h-4 w-4" />
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
className={cn(
|
||||
"bg-brand-color rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
|
||||
{getLocalizedValue(block?.buttonLabel, defaultLanguageCode)}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
</Container>
|
||||
)}
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
|
||||
@@ -6,13 +6,12 @@ import { ImagePlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
||||
@@ -45,7 +44,7 @@ interface QuestionFormInputProps {
|
||||
questionIdx: number;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyElement>) => void;
|
||||
updateSurvey?: (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => void;
|
||||
updateChoice?: (choiceIdx: number, data: Partial<TSurveyQuestionChoice>) => void;
|
||||
updateChoice?: (choiceIdx: number, data: Partial<TSurveyElementChoice>) => void;
|
||||
updateMatrixLabel?: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
@@ -314,6 +313,11 @@ export const QuestionFormInput = ({
|
||||
const getIsRequiredToggleDisabled = (): boolean => {
|
||||
if (!question) return false;
|
||||
|
||||
// CTA elements should always have the required toggle disabled
|
||||
if (question.type === TSurveyElementTypeEnum.CTA) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (question.type === TSurveyElementTypeEnum.Address) {
|
||||
const allFieldsAreOptional = [
|
||||
question.addressLine1,
|
||||
|
||||
@@ -108,7 +108,6 @@ export const isValueIncomplete = (
|
||||
"buttonLabel",
|
||||
"placeholder",
|
||||
"backButtonLabel",
|
||||
"dismissButtonLabel",
|
||||
];
|
||||
|
||||
// If value is not provided, immediately return false as it cannot be incomplete.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Project } from "@prisma/client";
|
||||
import { type Project } from "@prisma/client";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -36,6 +36,7 @@ import { PictureSelectionForm } from "@/modules/survey/editor/components/picture
|
||||
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
|
||||
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
|
||||
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
|
||||
@@ -46,8 +47,8 @@ interface BlockCardProps {
|
||||
blockIdx: number;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (blockIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
@@ -128,11 +129,293 @@ export const BlockCard = ({
|
||||
|
||||
const hasInvalidElement = block.elements.some((element) => invalidQuestions?.includes(element.id));
|
||||
|
||||
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
|
||||
// Check if button labels have incomplete translations for any enabled language
|
||||
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
|
||||
const hasInvalidButtonLabel =
|
||||
block.buttonLabel !== undefined &&
|
||||
!isLabelValidForAllLanguages(block.buttonLabel, localSurvey.languages ?? []);
|
||||
|
||||
// Check if back button label is invalid
|
||||
// Back button label should exist for all blocks except the first one
|
||||
const hasInvalidBackButtonLabel =
|
||||
blockIdx > 0 &&
|
||||
block.backButtonLabel !== undefined &&
|
||||
!isLabelValidForAllLanguages(block.backButtonLabel, localSurvey.languages ?? []);
|
||||
|
||||
// Block should be highlighted if it has invalid elements OR invalid button labels
|
||||
const isBlockInvalid = hasInvalidElement || hasInvalidButtonLabel || hasInvalidBackButtonLabel;
|
||||
|
||||
const [isBlockCollapsed, setIsBlockCollapsed] = useState(false);
|
||||
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
const [elementsParent] = useAutoAnimate();
|
||||
|
||||
const getElementHeadline = (
|
||||
element: TSurveyElement,
|
||||
languageCode: string
|
||||
): (string | React.ReactElement)[] | string | undefined => {
|
||||
const headlineData = recallToHeadline(element.headline, localSurvey, true, languageCode);
|
||||
const headlineText = headlineData[languageCode];
|
||||
if (headlineText) {
|
||||
return formatTextWithSlashes(getTextContent(headlineText ?? ""));
|
||||
}
|
||||
return getTSurveyQuestionTypeEnumName(element.type, t);
|
||||
};
|
||||
|
||||
const shouldShowCautionAlert = (elementType: TSurveyElementTypeEnum): boolean => {
|
||||
return (
|
||||
responseCount > 0 &&
|
||||
[
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.PictureSelection,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Ranking,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
].includes(elementType)
|
||||
);
|
||||
};
|
||||
|
||||
const renderElementForm = (element: TSurveyElement, questionIdx: number) => {
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
return (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
return (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
return (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
return (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
return (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
return (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return (
|
||||
<RankingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
return (
|
||||
<ContactInfoQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
@@ -142,6 +425,13 @@ export const BlockCard = ({
|
||||
const blockQuestionCount = block.elements.length;
|
||||
const blockQuestionCountText = blockQuestionCount === 1 ? "question" : "questions";
|
||||
|
||||
let blockSidebarColorClass = "";
|
||||
if (isBlockInvalid) {
|
||||
blockSidebarColorClass = "bg-red-400";
|
||||
} else {
|
||||
blockSidebarColorClass = isBlockOpen ? "bg-slate-700" : "bg-slate-400";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -155,7 +445,8 @@ export const BlockCard = ({
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
hasInvalidElement ? "bg-red-400" : isBlockOpen ? "bg-slate-700" : "bg-slate-400",
|
||||
// isBlockInvalid ? "bg-red-400" : isBlockOpen ? "bg-slate-700" : "bg-slate-400",
|
||||
blockSidebarColorClass,
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
"flex flex-col items-center justify-between gap-2"
|
||||
)}>
|
||||
@@ -186,11 +477,11 @@ export const BlockCard = ({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div>
|
||||
<BlockMenu
|
||||
blockIndex={blockIdx}
|
||||
isFirstBlock={blockIdx === 0}
|
||||
isLastBlock={blockIdx === totalBlocks - 1}
|
||||
isOnlyBlock={totalBlocks === 1}
|
||||
onDuplicate={() => duplicateBlock(block.id)}
|
||||
onDelete={() => deleteBlock(block.id)}
|
||||
onMoveUp={() => moveBlock(block.id, "up")}
|
||||
@@ -211,13 +502,12 @@ export const BlockCard = ({
|
||||
}
|
||||
questionIdx += elementIndex;
|
||||
|
||||
const isInvalid = invalidQuestions ? invalidQuestions.includes(element.id) : false;
|
||||
const open = activeQuestionId === element.id;
|
||||
const isOpen = activeQuestionId === element.id;
|
||||
|
||||
return (
|
||||
<div key={element.id} className={cn(elementIndex > 0 && "border-t border-slate-200")}>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
open={isOpen}
|
||||
onOpenChange={() => {
|
||||
if (activeQuestionId !== element.id) {
|
||||
setActiveQuestionId(element.id);
|
||||
@@ -229,7 +519,7 @@ export const BlockCard = ({
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
isOpen ? "bg-slate-50" : "",
|
||||
"flex w-full cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50"
|
||||
)}
|
||||
aria-label="Toggle question details">
|
||||
@@ -246,25 +536,9 @@ export const BlockCard = ({
|
||||
</p>
|
||||
)}
|
||||
<h3 className="text-sm font-semibold">
|
||||
{recallToHeadline(
|
||||
element.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode
|
||||
)[selectedLanguageCode]
|
||||
? formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(
|
||||
element.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode
|
||||
)[selectedLanguageCode] ?? ""
|
||||
)
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(element.type, t)}
|
||||
{getElementHeadline(element, selectedLanguageCode)}
|
||||
</h3>
|
||||
{!open && (
|
||||
{!isOpen && element.type !== TSurveyElementTypeEnum.CTA && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{element?.required
|
||||
? t("environments.surveys.edit.required")
|
||||
@@ -302,226 +576,16 @@ export const BlockCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
|
||||
{responseCount > 0 &&
|
||||
[
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.PictureSelection,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Ranking,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
].includes(element.type) ? (
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${isOpen && "pb-4"}`}>
|
||||
{shouldShowCautionAlert(element.type) && (
|
||||
<Alert variant="warning" size="small" className="w-fill" role="alert">
|
||||
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
|
||||
<AlertButton onClick={() => onAlertTrigger()}>
|
||||
{t("common.learn_more")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
) : null}
|
||||
{element.type === TSurveyElementTypeEnum.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.NPS ? (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.CTA ? (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Rating ? (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Address ? (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Ranking ? (
|
||||
<RankingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
{renderElementForm(element, questionIdx)}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root
|
||||
open={openAdvanced}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface BlockMenuProps {
|
||||
blockIndex: number;
|
||||
isFirstBlock: boolean;
|
||||
isLastBlock: boolean;
|
||||
isOnlyBlock: boolean;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
@@ -16,9 +16,9 @@ interface BlockMenuProps {
|
||||
}
|
||||
|
||||
export const BlockMenu = ({
|
||||
blockIndex,
|
||||
isFirstBlock,
|
||||
isLastBlock,
|
||||
isOnlyBlock,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
@@ -77,9 +77,12 @@ export const BlockMenu = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isOnlyBlock}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
if (!isOnlyBlock) {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic";
|
||||
|
||||
@@ -102,11 +103,15 @@ export const BlockSettings = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
onBlur={(e) => {
|
||||
if (!block.backButtonLabel) return;
|
||||
const translatedBackButtonLabel = {
|
||||
...block.backButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
const existingLabel = block.backButtonLabel || {};
|
||||
const translatedBackButtonLabel = addMultiLanguageLabels(
|
||||
{
|
||||
...existingLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
},
|
||||
languageSymbols
|
||||
);
|
||||
updateBlockButtonLabel(blockIndex, "backButtonLabel", translatedBackButtonLabel);
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, blockIndex);
|
||||
}}
|
||||
@@ -121,11 +126,17 @@ export const BlockSettings = ({
|
||||
isInvalid={false}
|
||||
updateQuestion={(_, updatedAttributes) => {
|
||||
if ("buttonLabel" in updatedAttributes) {
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
const buttonLabel = updatedAttributes.buttonLabel as TI18nString;
|
||||
updateBlockButtonLabel(blockIndex, "buttonLabel", {
|
||||
...block.buttonLabel,
|
||||
[selectedLanguageCode]: buttonLabel[selectedLanguageCode],
|
||||
});
|
||||
const existingLabel = block.buttonLabel || {};
|
||||
const updatedButtonLabel = addMultiLanguageLabels(
|
||||
{
|
||||
...existingLabel,
|
||||
[selectedLanguageCode]: buttonLabel[selectedLanguageCode],
|
||||
},
|
||||
languageSymbols
|
||||
);
|
||||
updateBlockButtonLabel(blockIndex, "buttonLabel", updatedButtonLabel);
|
||||
}
|
||||
}}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
@@ -134,11 +145,15 @@ export const BlockSettings = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
onBlur={(e) => {
|
||||
if (!block.buttonLabel) return;
|
||||
const translatedNextButtonLabel = {
|
||||
...block.buttonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
const existingLabel = block.buttonLabel || {};
|
||||
const translatedNextButtonLabel = addMultiLanguageLabels(
|
||||
{
|
||||
...existingLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
},
|
||||
languageSymbols
|
||||
);
|
||||
updateBlockButtonLabel(blockIndex, "buttonLabel", translatedNextButtonLabel);
|
||||
// Don't propagate to last block
|
||||
const lastBlockIndex = localSurvey.blocks.length - 1;
|
||||
|
||||
@@ -122,7 +122,7 @@ export const EditorCardMenu = ({
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
required: type === TSurveyElementTypeEnum.CTA ? false : required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
@@ -138,7 +138,7 @@ export const EditorCardMenu = ({
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
required: type === TSurveyElementTypeEnum.CTA ? false : true,
|
||||
};
|
||||
|
||||
// Add question to block or as new block
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TActionNumberVariableCalculateOperator,
|
||||
TActionTextVariableCalculateOperator,
|
||||
} from "@formbricks/types/surveys/logic";
|
||||
import { TActionVariableValueType, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getUpdatedActionBody } from "@/lib/surveyLogic/utils";
|
||||
import {
|
||||
getActionObjectiveOptions,
|
||||
@@ -218,7 +218,11 @@ export function LogicEditorActions({
|
||||
}}
|
||||
groupedOptions={getActionValueOptions(action.variableId, localSurvey, blockIdx, t)}
|
||||
onChangeValue={(val, option, fromInput) => {
|
||||
const fieldType = option?.meta?.type as TActionVariableValueType;
|
||||
const fieldType = option?.meta?.type as
|
||||
| "static"
|
||||
| "variable"
|
||||
| "hiddenField"
|
||||
| "element";
|
||||
|
||||
if (!fromInput && fieldType !== "static") {
|
||||
handleValuesChange(idx, {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,39 +39,23 @@ export function LogicEditor({
|
||||
isLast,
|
||||
}: LogicEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
|
||||
const blockLogicFallback = block.logicFallback;
|
||||
|
||||
const fallbackOptions = useMemo(() => {
|
||||
let options: {
|
||||
icon?: ReactElement;
|
||||
label: string;
|
||||
value: string;
|
||||
}[] = [];
|
||||
|
||||
const blocks = localSurvey.blocks;
|
||||
|
||||
// Track which blocks we've already added to avoid duplicates when a block has multiple elements
|
||||
const addedBlockIds = new Set<string>();
|
||||
|
||||
// Iterate over the elements AFTER the current block
|
||||
// Add blocks AFTER the current block
|
||||
for (let i = blockIdx + 1; i < blocks.length; i++) {
|
||||
const currentBlock = blocks[i];
|
||||
|
||||
if (addedBlockIds.has(currentBlock.id)) continue;
|
||||
|
||||
addedBlockIds.add(currentBlock.id);
|
||||
|
||||
// Use the first element's headline as the block label
|
||||
const firstElement = currentBlock.elements[0];
|
||||
if (!firstElement) continue;
|
||||
|
||||
options.push({
|
||||
icon: QUESTIONS_ICON_MAP[firstElement.type],
|
||||
label: getTextContent(
|
||||
recallToHeadline(firstElement.headline, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
label: currentBlock.name,
|
||||
value: currentBlock.id,
|
||||
});
|
||||
}
|
||||
@@ -92,7 +74,7 @@ export function LogicEditor({
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [localSurvey, blockIdx, QUESTIONS_ICON_MAP, t]);
|
||||
}, [localSurvey, blockIdx, t]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
|
||||
@@ -133,15 +115,12 @@ export function LogicEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="fallback_default_selection" value={"defaultSelection"}>
|
||||
{t("environments.surveys.edit.next_question")}
|
||||
{t("environments.surveys.edit.next_block")}
|
||||
</SelectItem>
|
||||
|
||||
{fallbackOptions.map((option) => (
|
||||
<SelectItem key={`fallback_${option.value}`} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</div>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -181,7 +181,7 @@ export const OpenQuestionForm = ({
|
||||
},
|
||||
});
|
||||
}}
|
||||
htmlId="charLimit"
|
||||
htmlId={`charLimit-${question.id}`}
|
||||
description={t("environments.surveys.edit.character_limit_toggle_description")}
|
||||
childBorder
|
||||
title={t("environments.surveys.edit.character_limit_toggle_title")}
|
||||
@@ -238,7 +238,7 @@ export const OpenQuestionForm = ({
|
||||
longAnswer: checked,
|
||||
});
|
||||
}}
|
||||
htmlId="longAnswer"
|
||||
htmlId={`longAnswer-${question.id}`}
|
||||
title={t("environments.surveys.edit.long_answer")}
|
||||
description={t("environments.surveys.edit.long_answer_toggle_description")}
|
||||
disabled={question.inputType !== "text"}
|
||||
|
||||
@@ -5,8 +5,12 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyMultipleChoiceElement, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyLanguage, TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurveyElementChoice,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
@@ -16,7 +20,7 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface ChoiceProps {
|
||||
choice: TSurveyQuestionChoice;
|
||||
choice: TSurveyElementChoice;
|
||||
choiceIdx: number;
|
||||
questionIdx: number;
|
||||
updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void;
|
||||
|
||||
@@ -600,7 +600,7 @@ export const QuestionsView = ({
|
||||
|
||||
// If source block is now empty, delete it
|
||||
if (sourceBlock.elements.length === 0) {
|
||||
const blockIdx = updatedSurvey.blocks.findIndex((b) => b.id === sourceBlock!.id);
|
||||
const blockIdx = updatedSurvey.blocks.findIndex((b) => b.id === sourceBlock.id);
|
||||
if (blockIdx !== -1) {
|
||||
updatedSurvey.blocks.splice(blockIdx, 1);
|
||||
}
|
||||
@@ -778,7 +778,6 @@ export const QuestionsView = ({
|
||||
blocks.splice(destBlockIndex, 0, movedBlock);
|
||||
|
||||
setLocalSurvey({ ...localSurvey, blocks });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -26,7 +25,6 @@ interface RatingQuestionFormProps {
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
buttonLabel?: TI18nString;
|
||||
}
|
||||
|
||||
export const RatingQuestionForm = ({
|
||||
@@ -40,7 +38,6 @@ export const RatingQuestionForm = ({
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
buttonLabel,
|
||||
}: RatingQuestionFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -181,27 +178,6 @@ export const RatingQuestionForm = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{!question.required && (
|
||||
<div className="flex-1">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
placeholder={"skip"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{question.scale !== "star" && (
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isColorCodingEnabled}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,11 @@ export const updateBlock = (
|
||||
* @returns Result with updated survey or Error
|
||||
*/
|
||||
export const deleteBlock = (survey: TSurvey, blockId: string): Result<TSurvey, Error> => {
|
||||
// Prevent deleting the last block
|
||||
if (survey.blocks?.length === 1) {
|
||||
return err(new Error("Cannot delete the last block in the survey"));
|
||||
}
|
||||
|
||||
const filteredBlocks = survey.blocks?.filter((b) => b.id !== blockId) || [];
|
||||
|
||||
if (filteredBlocks.length === survey.blocks?.length) {
|
||||
|
||||
@@ -258,8 +258,8 @@ describe("getLogicRules", () => {
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isClicked,
|
||||
},
|
||||
{
|
||||
label: "mockTranslate(environments.surveys.edit.is_skipped)",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
label: "mockTranslate(environments.surveys.edit.is_not_clicked)",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isNotClicked,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFunction } from "i18next";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuotaLogic } from "@formbricks/types/quota";
|
||||
import {
|
||||
@@ -87,8 +88,12 @@ vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
{ value: "notEquals", label: "not equals" },
|
||||
{ value: "isEmpty", label: "is empty" },
|
||||
]),
|
||||
getDefaultOperatorForQuestion: vi.fn().mockReturnValue("equals"),
|
||||
getDefaultOperatorForElement: vi.fn().mockReturnValue("equals"),
|
||||
getElementOperatorOptions: vi.fn().mockReturnValue([
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "notEquals", label: "not equals" },
|
||||
{ value: "isEmpty", label: "is empty" },
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
@@ -169,7 +174,7 @@ describe("shared-conditions-factory", () => {
|
||||
|
||||
const defaultParams: SharedConditionsFactoryParams = {
|
||||
survey: mockSurvey,
|
||||
t: mockT,
|
||||
t: mockT as unknown as TFunction,
|
||||
getDefaultOperator: mockGetDefaultOperator,
|
||||
};
|
||||
|
||||
@@ -244,15 +249,15 @@ describe("shared-conditions-factory", () => {
|
||||
result.config.getLeftOperandOptions();
|
||||
|
||||
const { getConditionValueOptions } = await import("@/modules/survey/editor/lib/utils");
|
||||
expect(getConditionValueOptions).toHaveBeenCalledWith(mockSurvey, mockT);
|
||||
expect(getConditionValueOptions).toHaveBeenCalledWith(mockSurvey, mockT, undefined);
|
||||
});
|
||||
|
||||
test("should call getConditionValueOptions with questionIdx", async () => {
|
||||
const paramsWithQuestionIdx = {
|
||||
const paramsWithBlockIdx = {
|
||||
...defaultParams,
|
||||
blockIdx: 0,
|
||||
};
|
||||
const result = createSharedConditionsFactory(paramsWithQuestionIdx, defaultCallbacks);
|
||||
const result = createSharedConditionsFactory(paramsWithBlockIdx, defaultCallbacks);
|
||||
|
||||
result.config.getLeftOperandOptions();
|
||||
|
||||
@@ -271,15 +276,15 @@ describe("shared-conditions-factory", () => {
|
||||
result.config.getValueProps(mockCondition);
|
||||
|
||||
const { getMatchValueProps } = await import("@/modules/survey/editor/lib/utils");
|
||||
expect(getMatchValueProps).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT);
|
||||
expect(getMatchValueProps).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT, undefined);
|
||||
});
|
||||
|
||||
test("should call getMatchValueProps with questionIdx", async () => {
|
||||
const paramsWithQuestionIdx = {
|
||||
const paramsWithBlockIdx = {
|
||||
...defaultParams,
|
||||
blockIdx: 0,
|
||||
};
|
||||
const result = createSharedConditionsFactory(paramsWithQuestionIdx, defaultCallbacks);
|
||||
const result = createSharedConditionsFactory(paramsWithBlockIdx, defaultCallbacks);
|
||||
const mockCondition: TSingleCondition = {
|
||||
id: "condition1",
|
||||
leftOperand: { value: "question1", type: "element" },
|
||||
@@ -301,6 +306,30 @@ describe("shared-conditions-factory", () => {
|
||||
expect(mockGetDefaultOperator).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should get operator options for condition", async () => {
|
||||
const { getConditionOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
|
||||
const mockGetConditionOperatorOptions = vi.mocked(getConditionOperatorOptions);
|
||||
mockGetConditionOperatorOptions.mockReturnValue([
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "doesNotEqual", label: "does not equal" },
|
||||
]);
|
||||
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const mockCondition: TSingleCondition = {
|
||||
id: "condition1",
|
||||
leftOperand: { value: "question1", type: "element" },
|
||||
operator: "equals",
|
||||
};
|
||||
|
||||
const operators = result.config.getOperatorOptions(mockCondition);
|
||||
|
||||
expect(mockGetConditionOperatorOptions).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT);
|
||||
expect(operators).toEqual([
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "doesNotEqual", label: "does not equal" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should format left operand value", async () => {
|
||||
const { getFormatLeftOperandValue } = await import("@/modules/survey/editor/lib/utils");
|
||||
const mockGetFormatLeftOperandValue = vi.mocked(getFormatLeftOperandValue);
|
||||
@@ -361,6 +390,139 @@ describe("shared-conditions-factory", () => {
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
test("onUpdateCondition should correct invalid operator for element type", async () => {
|
||||
const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
|
||||
const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
|
||||
|
||||
// Mock to return limited operators (e.g., only isEmpty and isNotEmpty)
|
||||
mockGetElementOperatorOptions.mockReturnValue([
|
||||
{ value: "isEmpty", label: "is empty" },
|
||||
{ value: "isNotEmpty", label: "is not empty" },
|
||||
]);
|
||||
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const resourceId = "condition1";
|
||||
const updates = {
|
||||
leftOperand: {
|
||||
value: "question1",
|
||||
type: "element" as const,
|
||||
},
|
||||
operator: "equals" as TSurveyLogicConditionsOperator, // Invalid operator for this element
|
||||
};
|
||||
|
||||
result.callbacks.onUpdateCondition(resourceId, updates);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
// Get the updater function that was called
|
||||
const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
|
||||
const mockConditions: TConditionGroup = {
|
||||
id: "root",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "condition1",
|
||||
leftOperand: { value: "oldQuestion", type: "element" },
|
||||
operator: "equals",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updater(structuredClone(mockConditions));
|
||||
|
||||
// Verify the operator was validated
|
||||
expect(mockGetElementOperatorOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("onUpdateCondition should handle update with valid operator", async () => {
|
||||
const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
|
||||
const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
|
||||
|
||||
// Mock to return operators that include the one being set
|
||||
mockGetElementOperatorOptions.mockReturnValue([
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "doesNotEqual", label: "does not equal" },
|
||||
]);
|
||||
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const resourceId = "condition1";
|
||||
const updates = {
|
||||
leftOperand: {
|
||||
value: "question1",
|
||||
type: "element" as const,
|
||||
},
|
||||
operator: "equals" as TSurveyLogicConditionsOperator, // Valid operator
|
||||
};
|
||||
|
||||
result.callbacks.onUpdateCondition(resourceId, updates);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
test("onUpdateCondition should handle update without leftOperand", () => {
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const resourceId = "condition1";
|
||||
const updates = {
|
||||
operator: "equals" as TSurveyLogicConditionsOperator,
|
||||
};
|
||||
|
||||
result.callbacks.onUpdateCondition(resourceId, updates);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
test("onUpdateCondition should handle update without operator", () => {
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const resourceId = "condition1";
|
||||
const updates = {
|
||||
leftOperand: {
|
||||
value: "question1",
|
||||
type: "element" as const,
|
||||
},
|
||||
};
|
||||
|
||||
result.callbacks.onUpdateCondition(resourceId, updates);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
test("onUpdateCondition should handle non-question leftOperand type", () => {
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const resourceId = "condition1";
|
||||
const updates = {
|
||||
leftOperand: {
|
||||
value: "variable1",
|
||||
type: "variable" as const,
|
||||
},
|
||||
operator: "equals" as TSurveyLogicConditionsOperator,
|
||||
};
|
||||
|
||||
result.callbacks.onUpdateCondition(resourceId, updates);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
test("onUpdateCondition should handle element not found", async () => {
|
||||
const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
|
||||
const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
|
||||
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const resourceId = "condition1";
|
||||
const updates = {
|
||||
leftOperand: {
|
||||
value: "non-existent-question",
|
||||
type: "element" as const,
|
||||
},
|
||||
operator: "equals" as TSurveyLogicConditionsOperator,
|
||||
};
|
||||
|
||||
result.callbacks.onUpdateCondition(resourceId, updates);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
// Should not call getElementOperatorOptions if element not found
|
||||
expect(mockGetElementOperatorOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("onToggleGroupConnector should toggle group connector", () => {
|
||||
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
|
||||
const groupId = "group1";
|
||||
@@ -368,6 +530,21 @@ describe("shared-conditions-factory", () => {
|
||||
result.callbacks.onToggleGroupConnector(groupId);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
// Execute the updater function to ensure it runs properly
|
||||
const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
|
||||
const mockConditions: TConditionGroup = {
|
||||
id: "root",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "group1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
updater(mockConditions);
|
||||
});
|
||||
|
||||
test("onCreateGroup should create group when includeCreateGroup is true", () => {
|
||||
@@ -381,6 +558,21 @@ describe("shared-conditions-factory", () => {
|
||||
result.callbacks.onCreateGroup!(resourceId);
|
||||
|
||||
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
// Execute the updater function to ensure it runs properly
|
||||
const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
|
||||
const mockConditions: TConditionGroup = {
|
||||
id: "root",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "condition1",
|
||||
leftOperand: { value: "question1", type: "question" },
|
||||
operator: "equals",
|
||||
},
|
||||
],
|
||||
};
|
||||
updater(mockConditions);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -91,15 +91,9 @@ export function createSharedConditionsFactory(
|
||||
};
|
||||
|
||||
const config: TConditionsEditorConfig<TSingleCondition> = {
|
||||
getLeftOperandOptions: () =>
|
||||
blockIdx !== undefined
|
||||
? getConditionValueOptions(survey, t, blockIdx)
|
||||
: getConditionValueOptions(survey, t),
|
||||
getLeftOperandOptions: () => getConditionValueOptions(survey, t, blockIdx),
|
||||
getOperatorOptions: (condition) => getConditionOperatorOptions(condition, survey, t),
|
||||
getValueProps: (condition) =>
|
||||
blockIdx !== undefined
|
||||
? getMatchValueProps(condition, survey, t, blockIdx)
|
||||
: getMatchValueProps(condition, survey, t),
|
||||
getValueProps: (condition) => getMatchValueProps(condition, survey, t, blockIdx),
|
||||
getDefaultOperator,
|
||||
formatLeftOperandValue: (condition) => getFormatLeftOperandValue(condition, survey),
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ import { isConditionGroup } from "@/lib/surveyLogic/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { getQuestionTypes, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
|
||||
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
|
||||
import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine";
|
||||
|
||||
@@ -106,6 +106,23 @@ const getQuestionIconMapping = (t: TFunction) =>
|
||||
{}
|
||||
);
|
||||
|
||||
const getElementHeadline = (
|
||||
localSurvey: TSurvey,
|
||||
element: TSurveyElement,
|
||||
languageCode: string,
|
||||
t: TFunction
|
||||
): string => {
|
||||
const headlineData = recallToHeadline(element.headline, localSurvey, false, languageCode);
|
||||
const headlineText = headlineData[languageCode];
|
||||
if (headlineText) {
|
||||
const textContent = getTextContent(headlineText);
|
||||
if (textContent.length > 0) {
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
return getTSurveyQuestionTypeEnumName(element.type, t) ?? "";
|
||||
};
|
||||
|
||||
export const getConditionValueOptions = (
|
||||
localSurvey: TSurvey,
|
||||
t: TFunction,
|
||||
@@ -117,11 +134,9 @@ export const getConditionValueOptions = (
|
||||
// If blockIdx is provided, get elements from current block and all previous blocks
|
||||
// Otherwise, get all elements from all blocks
|
||||
const allElements =
|
||||
blockIdx !== undefined
|
||||
? localSurvey.blocks
|
||||
.slice(0, blockIdx + 1) // Include blocks from 0 to blockIdx (inclusive)
|
||||
.flatMap((block) => block.elements)
|
||||
: getElementsFromBlocks(localSurvey.blocks);
|
||||
blockIdx === undefined
|
||||
? getElementsFromBlocks(localSurvey.blocks)
|
||||
: localSurvey.blocks.slice(0, blockIdx + 1).flatMap((block) => block.elements);
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
const elementOptions: TComboboxOption[] = [];
|
||||
@@ -133,9 +148,9 @@ export const getConditionValueOptions = (
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.Matrix) {
|
||||
const elementHeadline = getElementHeadline(localSurvey, element, "default", t);
|
||||
|
||||
// Rows submenu
|
||||
const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
|
||||
const elementHeadline = getTextContent(processedHeadline.default ?? "");
|
||||
const rows = element.rows.map((row, rowIdx) => {
|
||||
const processedLabel = recallToHeadline(row.label, localSurvey, false, "default");
|
||||
return {
|
||||
@@ -174,9 +189,7 @@ export const getConditionValueOptions = (
|
||||
} else {
|
||||
elementOptions.push({
|
||||
icon: getQuestionIconMapping(t)[element.type],
|
||||
label: getTextContent(
|
||||
recallToHeadline(element.headline, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
label: getElementHeadline(localSurvey, element, "default", t),
|
||||
value: element.id,
|
||||
meta: {
|
||||
type: "element",
|
||||
@@ -364,11 +377,11 @@ export const getMatchValueProps = (
|
||||
// If blockIdx is provided, get elements from current block and all previous blocks
|
||||
// Otherwise, get all elements from all blocks
|
||||
let elements =
|
||||
blockIdx !== undefined
|
||||
? localSurvey.blocks
|
||||
blockIdx === undefined
|
||||
? getElementsFromBlocks(localSurvey.blocks)
|
||||
: localSurvey.blocks
|
||||
.slice(0, blockIdx + 1) // Include blocks from 0 to blockIdx (inclusive)
|
||||
.flatMap((block) => block.elements)
|
||||
: getElementsFromBlocks(localSurvey.blocks);
|
||||
.flatMap((block) => block.elements);
|
||||
|
||||
let variables = localSurvey.variables ?? [];
|
||||
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
@@ -419,7 +432,7 @@ export const getMatchValueProps = (
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) =>
|
||||
selectedElement.inputType !== "number" ? variable.type === "text" : variable.type === "number"
|
||||
selectedElement.inputType === "number" ? variable.type === "number" : variable.type === "text"
|
||||
)
|
||||
.map((variable) => {
|
||||
return {
|
||||
@@ -721,10 +734,9 @@ export const getMatchValueProps = (
|
||||
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
|
||||
|
||||
const elementOptions = allowedElements.map((element) => {
|
||||
const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[element.type],
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
label: getElementHeadline(localSurvey, element, "default", t),
|
||||
value: element.id,
|
||||
meta: {
|
||||
type: "element",
|
||||
@@ -796,10 +808,9 @@ export const getMatchValueProps = (
|
||||
);
|
||||
|
||||
const elementOptions = allowedElements.map((element) => {
|
||||
const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[element.type],
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
label: getElementHeadline(localSurvey, element, "default", t),
|
||||
value: element.id,
|
||||
meta: {
|
||||
type: "element",
|
||||
@@ -877,10 +888,9 @@ export const getMatchValueProps = (
|
||||
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
|
||||
|
||||
const elementOptions = allowedElements.map((element) => {
|
||||
const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[element.type],
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
label: getElementHeadline(localSurvey, element, "default", t),
|
||||
value: element.id,
|
||||
meta: {
|
||||
type: "element",
|
||||
@@ -973,11 +983,10 @@ export const getActionTargetOptions = (
|
||||
|
||||
// Return element IDs for requireAnswer
|
||||
return nonRequiredElements.map((element) => {
|
||||
const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[element.type],
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
value: element.id, // Element ID
|
||||
label: getElementHeadline(localSurvey, element, "default", t),
|
||||
value: element.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1120,10 +1129,9 @@ export const getActionValueOptions = (
|
||||
);
|
||||
|
||||
const elementOptions = allowedElements.map((element) => {
|
||||
const processedHeadline = recallToHeadline(element.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[element.type],
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
label: getElementHeadline(localSurvey, element, "default", t),
|
||||
value: element.id,
|
||||
meta: {
|
||||
type: "element",
|
||||
|
||||
@@ -4,10 +4,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurveyAddressElement,
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyContactInfoElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMatrixElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyNPSElement,
|
||||
TSurveyOpenTextElement,
|
||||
TSurveyPictureSelectionElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -384,6 +391,422 @@ describe("validation.validateElement", () => {
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test MultipleChoiceMulti Element
|
||||
describe("MultipleChoiceMulti Element", () => {
|
||||
const mcMultiElementBase: TSurveyMultipleChoiceElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multi Choice", en: "Multi Choice", de: "Mehrfachauswahl" },
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } },
|
||||
{ id: "c2", label: { default: "Option 2", en: "Option 2", de: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
test("should return true for a valid MultipleChoiceMulti element", () => {
|
||||
expect(validation.validateElement(mcMultiElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if a choice label is invalid", () => {
|
||||
const q = {
|
||||
...mcMultiElementBase,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } },
|
||||
{ id: "c2", label: { default: "Option 2", en: "Option 2", de: "" } },
|
||||
],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if there are duplicate choice labels", () => {
|
||||
const q = {
|
||||
...mcMultiElementBase,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } },
|
||||
{ id: "c2", label: { default: "Option 1", en: "Option 1", de: "Option 1" } }, // Duplicate
|
||||
],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test Picture Selection Element
|
||||
describe("PictureSelection Element", () => {
|
||||
const pictureSelectionElementBase: TSurveyPictureSelectionElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Select Image", en: "Select Image", de: "Bild auswählen" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "https://example.com/img1.jpg" },
|
||||
{ id: "p2", imageUrl: "https://example.com/img2.jpg" },
|
||||
],
|
||||
allowMulti: false,
|
||||
};
|
||||
|
||||
test("should return true for a valid PictureSelection element with 2+ choices", () => {
|
||||
expect(validation.validateElement(pictureSelectionElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if PictureSelection has less than 2 choices", () => {
|
||||
const q = {
|
||||
...pictureSelectionElementBase,
|
||||
choices: [{ id: "p1", imageUrl: "https://example.com/img1.jpg" }],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if PictureSelection has 0 choices", () => {
|
||||
const q = {
|
||||
...pictureSelectionElementBase,
|
||||
choices: [],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test CTA Element
|
||||
describe("CTA Element", () => {
|
||||
const ctaElementBase: TSurveyCTAElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Call to Action", en: "Call to Action", de: "Handlungsaufforderung" },
|
||||
subheader: { default: "Click below", en: "Click below", de: "Klicke unten" },
|
||||
buttonExternal: false,
|
||||
required: false,
|
||||
};
|
||||
|
||||
test("should return true for a valid CTA element without external button", () => {
|
||||
expect(validation.validateElement(ctaElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for a valid CTA element with external button and valid label", () => {
|
||||
const q: TSurveyCTAElement = {
|
||||
...ctaElementBase,
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: { default: "Click", en: "Click", de: "Klicken" },
|
||||
buttonUrl: "https://example.com",
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for CTA with external button but invalid ctaButtonLabel", () => {
|
||||
const q: TSurveyCTAElement = {
|
||||
...ctaElementBase,
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: { default: "Click", en: "Click", de: "" }, // Invalid German label
|
||||
buttonUrl: "https://example.com",
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for CTA with buttonExternal true but no ctaButtonLabel", () => {
|
||||
const q: TSurveyCTAElement = {
|
||||
...ctaElementBase,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Test Matrix Element
|
||||
describe("Matrix Element", () => {
|
||||
const matrixElementBase: TSurveyMatrixElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Q", en: "Matrix Q", de: "Matrix F" },
|
||||
rows: [
|
||||
{ id: "r1", label: { default: "Row 1", en: "Row 1", de: "Zeile 1" } },
|
||||
{ id: "r2", label: { default: "Row 2", en: "Row 2", de: "Zeile 2" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "c1", label: { default: "Col 1", en: "Col 1", de: "Spalte 1" } },
|
||||
{ id: "c2", label: { default: "Col 2", en: "Col 2", de: "Spalte 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
test("should return true for a valid Matrix element", () => {
|
||||
expect(validation.validateElement(matrixElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if a row label is invalid", () => {
|
||||
const q = {
|
||||
...matrixElementBase,
|
||||
rows: [
|
||||
{ id: "r1", label: { default: "Row 1", en: "Row 1", de: "Zeile 1" } },
|
||||
{ id: "r2", label: { default: "Row 2", en: "Row 2", de: "" } }, // Invalid
|
||||
],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if a column label is invalid", () => {
|
||||
const q = {
|
||||
...matrixElementBase,
|
||||
columns: [
|
||||
{ id: "c1", label: { default: "Col 1", en: "Col 1", de: "Spalte 1" } },
|
||||
{ id: "c2", label: { default: "Col 2", en: "Col 2", de: "" } }, // Invalid
|
||||
],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if there are duplicate row labels", () => {
|
||||
const q = {
|
||||
...matrixElementBase,
|
||||
rows: [
|
||||
{ id: "r1", label: { default: "Row 1", en: "Row 1", de: "Zeile 1" } },
|
||||
{ id: "r2", label: { default: "Row 1", en: "Row 1", de: "Zeile 1" } }, // Duplicate
|
||||
],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if there are duplicate column labels", () => {
|
||||
const q = {
|
||||
...matrixElementBase,
|
||||
columns: [
|
||||
{ id: "c1", label: { default: "Col 1", en: "Col 1", de: "Spalte 1" } },
|
||||
{ id: "c2", label: { default: "Col 1", en: "Col 1", de: "Spalte 1" } }, // Duplicate
|
||||
],
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test ContactInfo Element
|
||||
describe("ContactInfo Element", () => {
|
||||
const contactInfoElementBase: TSurveyContactInfoElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact", en: "Contact", de: "Kontakt" },
|
||||
firstName: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "First Name", en: "First Name", de: "Vorname" },
|
||||
},
|
||||
lastName: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "Last Name", en: "Last Name", de: "Nachname" },
|
||||
},
|
||||
email: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "Email", en: "Email", de: "E-Mail" },
|
||||
},
|
||||
phone: { show: false, required: false, placeholder: { default: "Phone", en: "Phone", de: "Telefon" } },
|
||||
company: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: { default: "Company", en: "Company", de: "Firma" },
|
||||
},
|
||||
};
|
||||
|
||||
test("should return true for a valid ContactInfo element", () => {
|
||||
expect(validation.validateElement(contactInfoElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if a visible field placeholder is invalid", () => {
|
||||
const q = {
|
||||
...contactInfoElementBase,
|
||||
firstName: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "First Name", en: "First Name", de: "" }, // Invalid
|
||||
},
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if a hidden field placeholder is invalid (not shown)", () => {
|
||||
const q = {
|
||||
...contactInfoElementBase,
|
||||
phone: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: { default: "Phone", en: "Phone", de: "" }, // Invalid but hidden
|
||||
},
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Test Address Element
|
||||
describe("Address Element", () => {
|
||||
const addressElementBase: TSurveyAddressElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
headline: { default: "Address", en: "Address", de: "Adresse" },
|
||||
addressLine1: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "Address Line 1", en: "Address Line 1", de: "Adresszeile 1" },
|
||||
},
|
||||
addressLine2: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "Address Line 2", en: "Address Line 2", de: "Adresszeile 2" },
|
||||
},
|
||||
city: { show: true, required: false, placeholder: { default: "City", en: "City", de: "Stadt" } },
|
||||
state: { show: true, required: false, placeholder: { default: "State", en: "State", de: "Staat" } },
|
||||
zip: { show: true, required: false, placeholder: { default: "ZIP", en: "ZIP", de: "PLZ" } },
|
||||
country: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "Country", en: "Country", de: "Land" },
|
||||
},
|
||||
};
|
||||
|
||||
test("should return true for a valid Address element", () => {
|
||||
expect(validation.validateElement(addressElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if a visible field placeholder is invalid", () => {
|
||||
const q = {
|
||||
...addressElementBase,
|
||||
city: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: { default: "City", en: "City", de: "" }, // Invalid
|
||||
},
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if a hidden field placeholder is invalid (not shown)", () => {
|
||||
const q = {
|
||||
...addressElementBase,
|
||||
country: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: { default: "Country", en: "Country", de: "" }, // Invalid but hidden
|
||||
},
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Test default validation for elements with upperLabel and lowerLabel
|
||||
describe("Default validation for Rating and NPS elements", () => {
|
||||
const ratingElementBase: TSurveyRatingElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rating", en: "Rating", de: "Bewertung" },
|
||||
range: 5,
|
||||
scale: "number",
|
||||
lowerLabel: { default: "Bad", en: "Bad", de: "Schlecht" },
|
||||
upperLabel: { default: "Good", en: "Good", de: "Gut" },
|
||||
isColorCodingEnabled: false,
|
||||
};
|
||||
|
||||
test("should return true for a valid Rating element with valid upperLabel and lowerLabel", () => {
|
||||
expect(validation.validateElement(ratingElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if lowerLabel is invalid", () => {
|
||||
const q = {
|
||||
...ratingElementBase,
|
||||
lowerLabel: { default: "Bad", en: "Bad", de: "" }, // Invalid
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if upperLabel is invalid", () => {
|
||||
const q = {
|
||||
...ratingElementBase,
|
||||
upperLabel: { default: "Good", en: "Good", de: "" }, // Invalid
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if lowerLabel is empty/undefined (not validated)", () => {
|
||||
const q = {
|
||||
...ratingElementBase,
|
||||
lowerLabel: undefined,
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if lowerLabel.default is empty string (skipped)", () => {
|
||||
const q = {
|
||||
...ratingElementBase,
|
||||
lowerLabel: { default: "", en: "", de: "" },
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
const npsElementBase: TSurveyNPSElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "NPS", en: "NPS", de: "NPS" },
|
||||
lowerLabel: { default: "Not Likely", en: "Not Likely", de: "Unwahrscheinlich" },
|
||||
upperLabel: { default: "Very Likely", en: "Very Likely", de: "Sehr wahrscheinlich" },
|
||||
isColorCodingEnabled: false,
|
||||
};
|
||||
|
||||
test("should return true for a valid NPS element with valid upperLabel and lowerLabel", () => {
|
||||
expect(validation.validateElement(npsElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for NPS if lowerLabel is invalid", () => {
|
||||
const q = {
|
||||
...npsElementBase,
|
||||
lowerLabel: { default: "Not Likely", en: "Not Likely", de: "" },
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for NPS if upperLabel is invalid", () => {
|
||||
const q = {
|
||||
...npsElementBase,
|
||||
upperLabel: { default: "Very Likely", en: "Very Likely", de: "" },
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test subheader validation
|
||||
describe("Subheader validation", () => {
|
||||
test("should return false if subheader is invalid when provided", () => {
|
||||
const q: TSurveyOpenTextElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text", en: "Open Text", de: "Offener Text" },
|
||||
subheader: { default: "Enter here", en: "Enter here", de: "" }, // Invalid
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if subheader.default is empty (skipped)", () => {
|
||||
const q: TSurveyOpenTextElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text", en: "Open Text", de: "Offener Text" },
|
||||
subheader: { default: "", en: "", de: "" },
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if there's only one language (default)", () => {
|
||||
const q: TSurveyOpenTextElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text", en: "Open Text" },
|
||||
subheader: { default: "Some text", en: "Some text", de: "" }, // de empty but only default language
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
};
|
||||
expect(validation.validateElement(q, surveyLanguagesOnlyDefault)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation.validateSurveyElementsInBatch", () => {
|
||||
|
||||
@@ -121,8 +121,8 @@ export const validationRules = {
|
||||
return element.choices.length >= 2;
|
||||
},
|
||||
cta: (element: TSurveyCTAElement, languages: TSurveyLanguage[]) => {
|
||||
return !element.required && element.dismissButtonLabel
|
||||
? isLabelValidForAllLanguages(element.dismissButtonLabel, languages)
|
||||
return element.buttonExternal && element.ctaButtonLabel
|
||||
? isLabelValidForAllLanguages(element.ctaButtonLabel, languages)
|
||||
: true;
|
||||
},
|
||||
matrix: (element: TSurveyMatrixElement, languages: TSurveyLanguage[]) => {
|
||||
|
||||
@@ -172,6 +172,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [
|
||||
ctaButtonLabel: createI18nString(t("templates.book_interview"), []),
|
||||
buttonUrl: "",
|
||||
buttonExternal: true,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
let setQuestionId = (_: string) => {};
|
||||
let setBlockId = (_: string) => {};
|
||||
let setResponseData = (_: TResponseData) => {};
|
||||
|
||||
interface LinkSurveyProps {
|
||||
@@ -158,7 +158,11 @@ export const LinkSurvey = ({
|
||||
};
|
||||
|
||||
const handleResetSurvey = () => {
|
||||
setQuestionId(survey.welcomeCard.enabled ? "start" : questions[0].id);
|
||||
if (survey.welcomeCard.enabled) {
|
||||
setBlockId("start");
|
||||
} else if (survey.blocks[0]) {
|
||||
setBlockId(survey.blocks[0].id);
|
||||
}
|
||||
setResponseData({});
|
||||
};
|
||||
|
||||
@@ -191,8 +195,8 @@ export const LinkSurvey = ({
|
||||
prefillResponseData={prefillValue}
|
||||
skipPrefilled={skipPrefilled}
|
||||
responseCount={responseCount}
|
||||
getSetQuestionId={(f: (value: string) => void) => {
|
||||
setQuestionId = f;
|
||||
getSetBlockId={(f: (value: string) => void) => {
|
||||
setBlockId = f;
|
||||
}}
|
||||
getSetResponseData={(f: (value: TResponseData) => void) => {
|
||||
setResponseData = f;
|
||||
|
||||
@@ -1,495 +1,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { MigrationScript } from "../../src/scripts/migration-runner";
|
||||
|
||||
// Type definitions for migration
|
||||
type I18nString = Record<string, string>;
|
||||
|
||||
interface SurveyQuestion {
|
||||
id: string;
|
||||
type: string;
|
||||
headline?: I18nString;
|
||||
logic?: SurveyLogic[];
|
||||
logicFallback?: string;
|
||||
buttonLabel?: I18nString;
|
||||
backButtonLabel?: I18nString;
|
||||
buttonUrl?: string;
|
||||
buttonExternal?: boolean;
|
||||
dismissButtonLabel?: I18nString;
|
||||
ctaButtonLabel?: I18nString;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Single condition type (leaf node)
|
||||
interface SingleCondition {
|
||||
id: string;
|
||||
leftOperand: { value: string; type: string; meta?: Record<string, unknown> };
|
||||
operator: string;
|
||||
rightOperand?: { type: string; value: string | number | string[] };
|
||||
connector?: undefined; // Single conditions don't have connectors
|
||||
}
|
||||
|
||||
// Condition group type (has nested conditions)
|
||||
interface ConditionGroup {
|
||||
id: string;
|
||||
connector: "and" | "or";
|
||||
conditions: Condition[];
|
||||
}
|
||||
|
||||
// Union type for both
|
||||
type Condition = SingleCondition | ConditionGroup;
|
||||
|
||||
// Type guards
|
||||
const isSingleCondition = (condition: Condition): condition is SingleCondition => {
|
||||
return "leftOperand" in condition && "operator" in condition;
|
||||
};
|
||||
|
||||
const isConditionGroup = (condition: Condition): condition is ConditionGroup => {
|
||||
return "conditions" in condition && "connector" in condition;
|
||||
};
|
||||
|
||||
interface SurveyLogic {
|
||||
id: string;
|
||||
conditions: ConditionGroup; // Logic always starts with a condition group
|
||||
actions: LogicAction[];
|
||||
}
|
||||
|
||||
interface LogicAction {
|
||||
id: string;
|
||||
objective: string;
|
||||
target?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: SurveyQuestion[];
|
||||
logic?: SurveyLogic[];
|
||||
logicFallback?: string;
|
||||
buttonLabel?: I18nString;
|
||||
backButtonLabel?: I18nString;
|
||||
}
|
||||
|
||||
interface SurveyRecord {
|
||||
id: string;
|
||||
questions: SurveyQuestion[];
|
||||
blocks?: Block[];
|
||||
endings?: { id: string; [key: string]: unknown }[];
|
||||
}
|
||||
|
||||
interface MigratedSurvey {
|
||||
id: string;
|
||||
blocks: Block[];
|
||||
questions: SurveyQuestion[];
|
||||
}
|
||||
|
||||
// Statistics tracking for CTA migration
|
||||
interface CTAMigrationStats {
|
||||
totalCTAElements: number;
|
||||
ctaWithExternalLink: number;
|
||||
ctaWithoutExternalLink: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a condition references a CTA element with a specific operator
|
||||
* Can handle both SingleCondition and ConditionGroup
|
||||
*/
|
||||
const conditionReferencesCTA = (
|
||||
condition: Condition | null | undefined,
|
||||
ctaElementId: string,
|
||||
operator?: string
|
||||
): boolean => {
|
||||
if (!condition) return false;
|
||||
|
||||
// Check if it's a single condition
|
||||
if (isSingleCondition(condition)) {
|
||||
if (condition.leftOperand.value === ctaElementId) {
|
||||
if (operator) {
|
||||
return condition.operator === operator;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// It's a condition group - check nested conditions
|
||||
if (isConditionGroup(condition)) {
|
||||
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove conditions that reference a CTA element with specific operators
|
||||
*/
|
||||
const removeCtaConditions = (
|
||||
conditionGroup: ConditionGroup,
|
||||
ctaElementId: string,
|
||||
operatorsToRemove: string[]
|
||||
): ConditionGroup | null => {
|
||||
const filteredConditions = conditionGroup.conditions.filter((condition) => {
|
||||
// Check if it's a single condition referencing the CTA
|
||||
if (isSingleCondition(condition)) {
|
||||
if (condition.leftOperand.value === ctaElementId) {
|
||||
return !operatorsToRemove.includes(condition.operator);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// It's a condition group - recurse
|
||||
if (isConditionGroup(condition)) {
|
||||
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
|
||||
if (!cleaned || cleaned.conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Replace the condition with the cleaned version
|
||||
Object.assign(condition, cleaned);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filteredConditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...conditionGroup,
|
||||
conditions: filteredConditions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a single CTA question: update fields and clean logic
|
||||
*/
|
||||
const migrateCTAQuestion = (question: SurveyQuestion, stats: CTAMigrationStats): void => {
|
||||
if (question.type !== "cta") return;
|
||||
|
||||
stats.totalCTAElements++;
|
||||
|
||||
// Check if CTA has external link
|
||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
||||
|
||||
if (hasExternalButton) {
|
||||
stats.ctaWithExternalLink++;
|
||||
|
||||
// Copy buttonLabel to ctaButtonLabel
|
||||
if (question.buttonLabel) {
|
||||
question.ctaButtonLabel = question.buttonLabel;
|
||||
}
|
||||
|
||||
// Ensure buttonUrl and buttonExternal are set
|
||||
question.buttonExternal = true;
|
||||
} else {
|
||||
stats.ctaWithoutExternalLink++;
|
||||
// CTA without external link: remove buttonExternal and buttonUrl
|
||||
delete question.buttonExternal;
|
||||
delete question.buttonUrl;
|
||||
}
|
||||
|
||||
// Remove old fields that are no longer used
|
||||
delete question.buttonLabel;
|
||||
delete question.dismissButtonLabel;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean CTA logic from a question's logic array
|
||||
*/
|
||||
const cleanCTALogicFromQuestion = (question: SurveyQuestion, ctaQuestions: Map<string, boolean>): void => {
|
||||
if (!question.logic || question.logic.length === 0) return;
|
||||
|
||||
const cleanedLogic: SurveyLogic[] = [];
|
||||
|
||||
question.logic.forEach((logicRule) => {
|
||||
let shouldKeepRule = true;
|
||||
let modifiedConditions = logicRule.conditions;
|
||||
|
||||
// Check each CTA question
|
||||
ctaQuestions.forEach((hasExternalButton, ctaId) => {
|
||||
if (!hasExternalButton) {
|
||||
// CTA without external button - remove ALL conditions referencing this CTA
|
||||
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
|
||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
|
||||
"isClicked",
|
||||
"isSkipped",
|
||||
]);
|
||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
||||
shouldKeepRule = false;
|
||||
} else {
|
||||
modifiedConditions = cleanedConditions;
|
||||
}
|
||||
}
|
||||
} else if (conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped")) {
|
||||
// CTA with external button - remove isSkipped, keep isClicked
|
||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
|
||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
||||
shouldKeepRule = false;
|
||||
} else {
|
||||
modifiedConditions = cleanedConditions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- shouldKeepRule can be modified in loop
|
||||
if (shouldKeepRule) {
|
||||
cleanedLogic.push({
|
||||
...logicRule,
|
||||
conditions: modifiedConditions,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanedLogic.length === 0) {
|
||||
delete question.logic;
|
||||
} else {
|
||||
question.logic = cleanedLogic;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Process all CTA questions in a survey: migrate fields and clean logic
|
||||
*/
|
||||
const processCTAQuestions = (questions: SurveyQuestion[], stats: CTAMigrationStats): void => {
|
||||
// Build map of CTA question IDs to their external button status
|
||||
const ctaQuestions = new Map<string, boolean>();
|
||||
|
||||
questions.forEach((question) => {
|
||||
if (question.type === "cta") {
|
||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
||||
ctaQuestions.set(question.id, hasExternalButton);
|
||||
}
|
||||
});
|
||||
|
||||
if (ctaQuestions.size === 0) return;
|
||||
|
||||
// First pass: migrate CTA question fields
|
||||
questions.forEach((question) => {
|
||||
migrateCTAQuestion(question, stats);
|
||||
});
|
||||
|
||||
// Second pass: clean CTA logic from ALL questions
|
||||
questions.forEach((question) => {
|
||||
cleanCTALogicFromQuestion(question, ctaQuestions);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate block name from question headline or use index-based fallback
|
||||
* @param questionIdx - The 0-based index of the question in the survey
|
||||
* @returns Block name (e.g., "Block 1", "Block 2")
|
||||
*/
|
||||
const getBlockName = (questionIdx: number): string => {
|
||||
return `Block ${String(questionIdx + 1)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update logic actions: convert jumpToQuestion to jumpToBlock with new block IDs
|
||||
* @param actions - Array of logic actions
|
||||
* @param questionIdToBlockId - Map of question IDs to new block IDs
|
||||
* @param endingIds - Set of valid ending card IDs
|
||||
* @returns Updated actions array with jumpToBlock objectives
|
||||
*/
|
||||
const updateLogicActions = (
|
||||
actions: LogicAction[],
|
||||
questionIdToBlockId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): LogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
const target = action.target ?? "";
|
||||
const blockId = questionIdToBlockId.get(target);
|
||||
|
||||
if (blockId) {
|
||||
// Target is a question ID - convert to block ID
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target: blockId,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if target is a valid ending card ID
|
||||
if (endingIds.has(target)) {
|
||||
// Target is an ending card - keep it as is but change objective
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
// Target is neither a question nor an ending card - keep as is
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
// calculate and requireAnswer stay unchanged
|
||||
return action;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update logic fallback: convert question ID to block ID
|
||||
* @param fallback - The fallback question ID or ending card ID
|
||||
* @param questionIdToBlockId - Map of question IDs to new block IDs
|
||||
* @param endingIds - Set of valid ending card IDs
|
||||
* @returns Updated fallback with block ID, unchanged ending card ID, or undefined if invalid
|
||||
*/
|
||||
const updateLogicFallback = (
|
||||
fallback: string,
|
||||
questionIdToBlockId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): string | undefined => {
|
||||
const blockId = questionIdToBlockId.get(fallback);
|
||||
|
||||
if (blockId) {
|
||||
// Fallback is a question ID - convert to block ID
|
||||
return blockId;
|
||||
}
|
||||
|
||||
// Check if fallback is a valid ending card ID
|
||||
if (endingIds.has(fallback)) {
|
||||
// Fallback is an ending card - keep it as is
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Fallback is neither a question nor an ending card - remove it
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert logic operand types from "question" to "element" recursively (immutable)
|
||||
* @param condition - Condition or condition group to convert
|
||||
* @returns New condition object with "element" type instead of "question"
|
||||
*/
|
||||
const convertQuestionToElementType = (condition: Condition | null | undefined): Condition | null => {
|
||||
if (!condition) return null;
|
||||
|
||||
// Handle single condition
|
||||
if (isSingleCondition(condition)) {
|
||||
const newCondition: SingleCondition = { ...condition };
|
||||
|
||||
// Update leftOperand if it's of type "question"
|
||||
if (condition.leftOperand.type === "question") {
|
||||
newCondition.leftOperand = {
|
||||
...condition.leftOperand,
|
||||
type: "element",
|
||||
};
|
||||
}
|
||||
|
||||
// Update rightOperand if it exists and is of type "question"
|
||||
if (condition.rightOperand && condition.rightOperand.type === "question") {
|
||||
newCondition.rightOperand = {
|
||||
...condition.rightOperand,
|
||||
type: "element",
|
||||
};
|
||||
}
|
||||
|
||||
return newCondition;
|
||||
}
|
||||
|
||||
// Handle condition group
|
||||
if (isConditionGroup(condition)) {
|
||||
const newConditionGroup: ConditionGroup = {
|
||||
...condition,
|
||||
conditions: condition.conditions.map((nestedCondition) => {
|
||||
const converted = convertQuestionToElementType(nestedCondition);
|
||||
return converted ?? nestedCondition;
|
||||
}),
|
||||
};
|
||||
|
||||
return newConditionGroup;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a survey from questions to blocks structure
|
||||
* Each question becomes a block with a single element
|
||||
* @param survey - Survey record with questions
|
||||
* @param createIdFn - Function to generate CUIDs for blocks
|
||||
* @param ctaStats - Statistics tracker for CTA migration
|
||||
* @returns Migrated survey with blocks and empty questions array
|
||||
*/
|
||||
const migrateQuestionsSurveyToBlocks = (
|
||||
survey: SurveyRecord,
|
||||
createIdFn: () => string,
|
||||
ctaStats: CTAMigrationStats
|
||||
): MigratedSurvey => {
|
||||
// Skip if no questions
|
||||
if (survey.questions.length === 0) {
|
||||
return { ...survey, blocks: survey.blocks ?? [], questions: [] };
|
||||
}
|
||||
|
||||
// STEP 1: Process CTA questions FIRST (before converting to blocks)
|
||||
processCTAQuestions(survey.questions, ctaStats);
|
||||
|
||||
// Create set of valid ending card IDs for validation
|
||||
const endingIds = new Set<string>((survey.endings ?? []).map((ending) => ending.id));
|
||||
|
||||
// Phase 1: Create blocks and ID mapping
|
||||
const questionIdToBlockId = new Map<string, string>();
|
||||
const blocks: Block[] = [];
|
||||
|
||||
for (let i = 0; i < survey.questions.length; i++) {
|
||||
const question = survey.questions[i];
|
||||
|
||||
const blockId = createIdFn();
|
||||
questionIdToBlockId.set(question.id, blockId);
|
||||
|
||||
// Extract logic from question level
|
||||
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
|
||||
|
||||
blocks.push({
|
||||
id: blockId,
|
||||
name: getBlockName(i),
|
||||
elements: [baseElement],
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic, // Will update in Phase 2
|
||||
logicFallback, // Will update in Phase 2
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Update all logic references
|
||||
for (const block of blocks) {
|
||||
if (block.logic && block.logic.length > 0) {
|
||||
block.logic = block.logic.map((item) => {
|
||||
// Convert "question" type to "element" type in conditions (immutably)
|
||||
const updatedConditions = convertQuestionToElementType(item.conditions);
|
||||
|
||||
// Since item.conditions is always a ConditionGroup, the result should be too
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
// This should never happen, but if it does, keep the original
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: updateLogicActions(item.actions, questionIdToBlockId, endingIds),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (block.logicFallback) {
|
||||
block.logicFallback = updateLogicFallback(block.logicFallback, questionIdToBlockId, endingIds);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...survey,
|
||||
blocks,
|
||||
questions: [],
|
||||
};
|
||||
};
|
||||
import type { Block, CTAMigrationStats, SurveyRecord } from "./types";
|
||||
import { migrateQuestionsSurveyToBlocks } from "./utils";
|
||||
|
||||
export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
type: "data",
|
||||
@@ -518,8 +31,7 @@ export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
logger.info(`Found ${surveys.length.toString()} surveys to migrate`);
|
||||
|
||||
// 2. Process each survey
|
||||
const updates: { id: string; blocks: Block[]; questions: SurveyQuestion[] }[] = [];
|
||||
let failedCount = 0;
|
||||
const updates: { id: string; blocks: Block[] }[] = [];
|
||||
|
||||
for (const survey of surveys) {
|
||||
try {
|
||||
@@ -527,22 +39,16 @@ export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
updates.push({
|
||||
id: migrated.id,
|
||||
blocks: migrated.blocks,
|
||||
questions: [],
|
||||
});
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
logger.error(error, `Failed to migrate survey ${survey.id}`);
|
||||
throw new Error(
|
||||
`Migration failed for survey ${survey.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
logger.error(`All ${failedCount.toString()} surveys failed migration`);
|
||||
throw new Error("Migration failed for all surveys");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully processed ${updates.length.toString()} surveys, ${failedCount.toString()} failed`
|
||||
);
|
||||
logger.info(`Successfully processed ${updates.length.toString()} surveys`);
|
||||
|
||||
// 3. Update surveys individually for safety (avoids SQL injection risks with complex JSONB arrays)
|
||||
let updatedCount = 0;
|
||||
@@ -558,10 +64,9 @@ export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
SELECT array_agg(elem)
|
||||
FROM jsonb_array_elements($1::jsonb) AS elem
|
||||
),
|
||||
questions = $2::jsonb
|
||||
WHERE id = $3`,
|
||||
questions = '[]'::jsonb
|
||||
WHERE id = $2`,
|
||||
JSON.stringify(update.blocks),
|
||||
JSON.stringify(update.questions),
|
||||
update.id
|
||||
);
|
||||
|
||||
@@ -573,16 +78,14 @@ export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update survey ${update.id} in database`);
|
||||
failedCount++;
|
||||
throw new Error(
|
||||
`Database update failed for survey ${update.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated to blocks`);
|
||||
|
||||
if (failedCount > 0) {
|
||||
logger.warn(`Warning: ${failedCount.toString()} surveys failed and need manual review`);
|
||||
}
|
||||
|
||||
// 4. Log CTA migration statistics
|
||||
if (ctaStats.totalCTAElements > 0) {
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export type I18nString = Record<string, string>;
|
||||
|
||||
export interface SurveyQuestion {
|
||||
id: string;
|
||||
type: string;
|
||||
headline?: I18nString;
|
||||
logic?: SurveyLogic[];
|
||||
logicFallback?: string;
|
||||
buttonLabel?: I18nString;
|
||||
backButtonLabel?: I18nString;
|
||||
buttonUrl?: string;
|
||||
buttonExternal?: boolean;
|
||||
dismissButtonLabel?: I18nString;
|
||||
ctaButtonLabel?: I18nString;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Single condition type (leaf node)
|
||||
export interface SingleCondition {
|
||||
id: string;
|
||||
leftOperand: { value: string; type: string; meta?: Record<string, unknown> };
|
||||
operator: string;
|
||||
rightOperand?: { type: string; value: string | number | string[] };
|
||||
connector?: undefined; // Single conditions don't have connectors
|
||||
}
|
||||
|
||||
// Condition group type (has nested conditions)
|
||||
export interface ConditionGroup {
|
||||
id: string;
|
||||
connector: "and" | "or";
|
||||
conditions: Condition[];
|
||||
}
|
||||
|
||||
// Union type for both
|
||||
export type Condition = SingleCondition | ConditionGroup;
|
||||
|
||||
export interface SurveyLogic {
|
||||
id: string;
|
||||
conditions: ConditionGroup; // Logic always starts with a condition group
|
||||
actions: LogicAction[];
|
||||
}
|
||||
|
||||
export interface LogicAction {
|
||||
id: string;
|
||||
objective: string;
|
||||
target?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: SurveyQuestion[];
|
||||
logic?: SurveyLogic[];
|
||||
logicFallback?: string;
|
||||
buttonLabel?: I18nString;
|
||||
backButtonLabel?: I18nString;
|
||||
}
|
||||
|
||||
export interface SurveyRecord {
|
||||
id: string;
|
||||
questions: SurveyQuestion[];
|
||||
blocks?: Block[];
|
||||
endings?: { id: string; [key: string]: unknown }[];
|
||||
}
|
||||
|
||||
export interface MigratedSurvey {
|
||||
id: string;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
// Statistics tracking for CTA migration
|
||||
export interface CTAMigrationStats {
|
||||
totalCTAElements: number;
|
||||
ctaWithExternalLink: number;
|
||||
ctaWithoutExternalLink: number;
|
||||
}
|
||||
|
||||
// Type guards
|
||||
export const isSingleCondition = (condition: Condition): condition is SingleCondition => {
|
||||
return "leftOperand" in condition && "operator" in condition;
|
||||
};
|
||||
|
||||
export const isConditionGroup = (condition: Condition): condition is ConditionGroup => {
|
||||
return "conditions" in condition && "connector" in condition;
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
type Block,
|
||||
type CTAMigrationStats,
|
||||
type Condition,
|
||||
type ConditionGroup,
|
||||
type LogicAction,
|
||||
type MigratedSurvey,
|
||||
type SingleCondition,
|
||||
type SurveyLogic,
|
||||
type SurveyQuestion,
|
||||
type SurveyRecord,
|
||||
isConditionGroup as checkIsConditionGroup,
|
||||
isSingleCondition as checkIsSingleCondition,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Check if a condition references a CTA element with a specific operator
|
||||
* Can handle both SingleCondition and ConditionGroup
|
||||
*/
|
||||
export const conditionReferencesCTA = (
|
||||
condition: Condition | null | undefined,
|
||||
ctaElementId: string,
|
||||
operator?: string
|
||||
): boolean => {
|
||||
if (!condition) return false;
|
||||
|
||||
// Check if it's a single condition
|
||||
if (checkIsSingleCondition(condition)) {
|
||||
if (condition.leftOperand.value === ctaElementId) {
|
||||
if (operator) {
|
||||
return condition.operator === operator;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// It's a condition group - check nested conditions
|
||||
if (checkIsConditionGroup(condition)) {
|
||||
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove conditions that reference a CTA element with specific operators
|
||||
*/
|
||||
export const removeCtaConditions = (
|
||||
conditionGroup: ConditionGroup,
|
||||
ctaElementId: string,
|
||||
operatorsToRemove: string[]
|
||||
): ConditionGroup | null => {
|
||||
const filteredConditions = conditionGroup.conditions.filter((condition) => {
|
||||
// Check if it's a single condition referencing the CTA
|
||||
if (checkIsSingleCondition(condition)) {
|
||||
if (condition.leftOperand.value === ctaElementId) {
|
||||
return !operatorsToRemove.includes(condition.operator);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// It's a condition group - recurse
|
||||
if (checkIsConditionGroup(condition)) {
|
||||
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
|
||||
if (!cleaned || cleaned.conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Replace the condition with the cleaned version
|
||||
Object.assign(condition, cleaned);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filteredConditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...conditionGroup,
|
||||
conditions: filteredConditions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a single CTA question: update fields and clean logic
|
||||
*/
|
||||
export const migrateCTAQuestion = (question: SurveyQuestion, stats: CTAMigrationStats): void => {
|
||||
if (question.type !== "cta") return;
|
||||
|
||||
stats.totalCTAElements++;
|
||||
|
||||
// Check if CTA has external link
|
||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
||||
|
||||
if (hasExternalButton) {
|
||||
stats.ctaWithExternalLink++;
|
||||
|
||||
// Copy buttonLabel to ctaButtonLabel
|
||||
if (question.buttonLabel) {
|
||||
question.ctaButtonLabel = question.buttonLabel;
|
||||
}
|
||||
|
||||
// Ensure buttonUrl and buttonExternal are set
|
||||
question.buttonExternal = true;
|
||||
} else {
|
||||
stats.ctaWithoutExternalLink++;
|
||||
// CTA without external link: remove buttonExternal and buttonUrl
|
||||
delete question.buttonExternal;
|
||||
delete question.buttonUrl;
|
||||
}
|
||||
|
||||
// Remove old fields that are no longer used
|
||||
delete question.buttonLabel;
|
||||
delete question.dismissButtonLabel;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean CTA logic from a question's logic array
|
||||
*/
|
||||
export const cleanCTALogicFromQuestion = (
|
||||
question: SurveyQuestion,
|
||||
ctaQuestions: Map<string, boolean>
|
||||
): void => {
|
||||
if (!question.logic || question.logic.length === 0) return;
|
||||
|
||||
const cleanedLogic: SurveyLogic[] = [];
|
||||
|
||||
question.logic.forEach((logicRule) => {
|
||||
let shouldKeepRule = true;
|
||||
let modifiedConditions = logicRule.conditions;
|
||||
|
||||
// Check each CTA question
|
||||
ctaQuestions.forEach((hasExternalButton, ctaId) => {
|
||||
if (!hasExternalButton) {
|
||||
// CTA without external button - remove ALL conditions referencing this CTA
|
||||
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
|
||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
|
||||
"isClicked",
|
||||
"isSkipped",
|
||||
]);
|
||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
||||
shouldKeepRule = false;
|
||||
} else {
|
||||
modifiedConditions = cleanedConditions;
|
||||
}
|
||||
}
|
||||
} else if (conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped")) {
|
||||
// CTA with external button - remove isSkipped, keep isClicked
|
||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
|
||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
||||
shouldKeepRule = false;
|
||||
} else {
|
||||
modifiedConditions = cleanedConditions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- shouldKeepRule can be modified in loop
|
||||
if (shouldKeepRule) {
|
||||
cleanedLogic.push({
|
||||
...logicRule,
|
||||
conditions: modifiedConditions,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanedLogic.length === 0) {
|
||||
delete question.logic;
|
||||
} else {
|
||||
question.logic = cleanedLogic;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Process all CTA questions in a survey: migrate fields and clean logic
|
||||
*/
|
||||
export const processCTAQuestions = (questions: SurveyQuestion[], stats: CTAMigrationStats): void => {
|
||||
// Build map of CTA question IDs to their external button status
|
||||
const ctaQuestions = new Map<string, boolean>();
|
||||
|
||||
questions.forEach((question) => {
|
||||
if (question.type === "cta") {
|
||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
||||
ctaQuestions.set(question.id, hasExternalButton);
|
||||
}
|
||||
});
|
||||
|
||||
if (ctaQuestions.size === 0) return;
|
||||
|
||||
// First pass: migrate CTA question fields
|
||||
questions.forEach((question) => {
|
||||
migrateCTAQuestion(question, stats);
|
||||
});
|
||||
|
||||
// Second pass: clean CTA logic from ALL questions
|
||||
questions.forEach((question) => {
|
||||
cleanCTALogicFromQuestion(question, ctaQuestions);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate block name from question headline or use index-based fallback
|
||||
* @param questionIdx - The 0-based index of the question in the survey
|
||||
* @returns Block name (e.g., "Block 1", "Block 2")
|
||||
*/
|
||||
export const getBlockName = (questionIdx: number): string => {
|
||||
return `Block ${String(questionIdx + 1)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update logic actions: convert jumpToQuestion to jumpToBlock with new block IDs
|
||||
* @param actions - Array of logic actions
|
||||
* @param questionIdToBlockId - Map of question IDs to new block IDs
|
||||
* @param endingIds - Set of valid ending card IDs
|
||||
* @returns Updated actions array with jumpToBlock objectives
|
||||
*/
|
||||
export const updateLogicActions = (
|
||||
actions: LogicAction[],
|
||||
questionIdToBlockId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): LogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
const target = action.target ?? "";
|
||||
const blockId = questionIdToBlockId.get(target);
|
||||
|
||||
if (blockId) {
|
||||
// Target is a question ID - convert to block ID
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target: blockId,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if target is a valid ending card ID
|
||||
if (endingIds.has(target)) {
|
||||
// Target is an ending card - keep it as is but change objective
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
// Target is neither a question nor an ending card - keep as is
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
// calculate and requireAnswer stay unchanged
|
||||
return action;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update logic fallback: convert question ID to block ID
|
||||
* @param fallback - The fallback question ID or ending card ID
|
||||
* @param questionIdToBlockId - Map of question IDs to new block IDs
|
||||
* @param endingIds - Set of valid ending card IDs
|
||||
* @returns Updated fallback with block ID, unchanged ending card ID, or undefined if invalid
|
||||
*/
|
||||
export const updateLogicFallback = (
|
||||
fallback: string,
|
||||
questionIdToBlockId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): string | undefined => {
|
||||
const blockId = questionIdToBlockId.get(fallback);
|
||||
|
||||
if (blockId) {
|
||||
// Fallback is a question ID - convert to block ID
|
||||
return blockId;
|
||||
}
|
||||
|
||||
// Check if fallback is a valid ending card ID
|
||||
if (endingIds.has(fallback)) {
|
||||
// Fallback is an ending card - keep it as is
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Fallback is neither a question nor an ending card - remove it
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert logic operand types from "question" to "element" recursively (immutable)
|
||||
* @param condition - Condition or condition group to convert
|
||||
* @returns New condition object with "element" type instead of "question"
|
||||
*/
|
||||
export const convertQuestionToElementType = (condition: Condition | null | undefined): Condition | null => {
|
||||
if (!condition) return null;
|
||||
|
||||
// Handle single condition
|
||||
if (checkIsSingleCondition(condition)) {
|
||||
const newCondition: SingleCondition = { ...condition };
|
||||
|
||||
// Update leftOperand if it's of type "question"
|
||||
if (condition.leftOperand.type === "question") {
|
||||
newCondition.leftOperand = {
|
||||
...condition.leftOperand,
|
||||
type: "element",
|
||||
};
|
||||
}
|
||||
|
||||
// Update rightOperand if it exists and is of type "question"
|
||||
if (condition.rightOperand && condition.rightOperand.type === "question") {
|
||||
newCondition.rightOperand = {
|
||||
...condition.rightOperand,
|
||||
type: "element",
|
||||
};
|
||||
}
|
||||
|
||||
return newCondition;
|
||||
}
|
||||
|
||||
// Handle condition group
|
||||
if (checkIsConditionGroup(condition)) {
|
||||
const newConditionGroup: ConditionGroup = {
|
||||
...condition,
|
||||
conditions: condition.conditions.map((nestedCondition) => {
|
||||
const converted = convertQuestionToElementType(nestedCondition);
|
||||
return converted ?? nestedCondition;
|
||||
}),
|
||||
};
|
||||
|
||||
return newConditionGroup;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a survey from questions to blocks structure
|
||||
* Each question becomes a block with a single element
|
||||
* @param survey - Survey record with questions
|
||||
* @param createIdFn - Function to generate CUIDs for blocks
|
||||
* @param ctaStats - Statistics tracker for CTA migration
|
||||
* @returns Migrated survey with blocks and empty questions array
|
||||
*/
|
||||
export const migrateQuestionsSurveyToBlocks = (
|
||||
survey: SurveyRecord,
|
||||
createIdFn: () => string,
|
||||
ctaStats: CTAMigrationStats
|
||||
): MigratedSurvey => {
|
||||
// Skip if no questions
|
||||
if (survey.questions.length === 0) {
|
||||
return { id: survey.id, blocks: survey.blocks ?? [] };
|
||||
}
|
||||
|
||||
// STEP 1: Process CTA questions FIRST (before converting to blocks)
|
||||
processCTAQuestions(survey.questions, ctaStats);
|
||||
|
||||
// Create set of valid ending card IDs for validation
|
||||
const endingIds = new Set<string>((survey.endings ?? []).map((ending) => ending.id));
|
||||
|
||||
// Phase 1: Create blocks and ID mapping
|
||||
const questionIdToBlockId = new Map<string, string>();
|
||||
const blocks: Block[] = [];
|
||||
|
||||
for (let i = 0; i < survey.questions.length; i++) {
|
||||
const question = survey.questions[i];
|
||||
|
||||
const blockId = createIdFn();
|
||||
questionIdToBlockId.set(question.id, blockId);
|
||||
|
||||
// Extract logic from question level
|
||||
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
|
||||
|
||||
blocks.push({
|
||||
id: blockId,
|
||||
name: getBlockName(i),
|
||||
elements: [baseElement],
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic, // Will update in Phase 2
|
||||
logicFallback, // Will update in Phase 2
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Update all logic references
|
||||
for (const block of blocks) {
|
||||
if (block.logic && block.logic.length > 0) {
|
||||
block.logic = block.logic.map((item) => {
|
||||
// Convert "question" type to "element" type in conditions (immutably)
|
||||
const updatedConditions = convertQuestionToElementType(item.conditions);
|
||||
|
||||
// Since item.conditions is always a ConditionGroup, the result should be too
|
||||
if (!updatedConditions || !checkIsConditionGroup(updatedConditions)) {
|
||||
// This should never happen, but if it does, keep the original
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: updateLogicActions(item.actions, questionIdToBlockId, endingIds),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (block.logicFallback) {
|
||||
block.logicFallback = updateLogicFallback(block.logicFallback, questionIdToBlockId, endingIds);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
blocks,
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,6 @@ export interface SurveyBaseProps {
|
||||
isBrandingEnabled: boolean;
|
||||
getSetIsError?: (getSetError: (value: boolean) => void) => void;
|
||||
getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
|
||||
getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void;
|
||||
getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void;
|
||||
onDisplay?: () => void;
|
||||
onResponse?: (response: TResponseUpdate) => void;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BlockConditionalProps {
|
||||
// survey: TJsEnvironmentStateSurvey;
|
||||
block: TSurveyBlock;
|
||||
value: TResponseData;
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
@@ -28,7 +27,6 @@ interface BlockConditionalProps {
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
surveyId: string;
|
||||
autoFocusEnabled: boolean;
|
||||
currentBlockId: string;
|
||||
isBackButtonHidden: boolean;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
@@ -36,7 +34,6 @@ interface BlockConditionalProps {
|
||||
}
|
||||
|
||||
export function BlockConditional({
|
||||
// survey,
|
||||
block,
|
||||
value,
|
||||
onChange,
|
||||
@@ -210,8 +207,6 @@ export function BlockConditional({
|
||||
onChange={(responseData) => handleElementChange(element.id, responseData)}
|
||||
onBack={() => {}}
|
||||
onFileUpload={onFileUpload}
|
||||
isFirstElement={false}
|
||||
isLastElement={false}
|
||||
languageCode={languageCode}
|
||||
prefilledElementValue={prefilledResponseData?.[element.id]}
|
||||
skipPrefilled={skipPrefilled}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useRef } from "preact/hooks";
|
||||
import { 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 { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { type TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { AddressQuestion } from "@/components/questions/address-question";
|
||||
import { CalQuestion } from "@/components/questions/cal-question";
|
||||
import { ConsentQuestion } from "@/components/questions/consent-question";
|
||||
@@ -27,8 +30,6 @@ interface ElementConditionalProps {
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onBack: () => void;
|
||||
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
isFirstElement: boolean;
|
||||
isLastElement: boolean;
|
||||
languageCode: string;
|
||||
prefilledElementValue?: TResponseDataValue;
|
||||
skipPrefilled?: boolean;
|
||||
@@ -74,10 +75,7 @@ export function ElementConditional({
|
||||
}
|
||||
}, [formRef]);
|
||||
|
||||
const getResponseValueForRankingQuestion = (
|
||||
value: string[],
|
||||
choices: TSurveyQuestionChoice[]
|
||||
): string[] => {
|
||||
const getResponseValueForRankingQuestion = (value: string[], choices: TSurveyElementChoice[]): string[] => {
|
||||
return value
|
||||
.map((entry) => {
|
||||
// First check if entry is already a valid choice ID
|
||||
@@ -87,7 +85,7 @@ export function ElementConditional({
|
||||
// Otherwise, treat it as a localized label and find the choice by label
|
||||
return choices.find((choice) => getLocalizedValue(choice.label, languageCode) === entry)?.id;
|
||||
})
|
||||
.filter((id): id is TSurveyQuestionChoice["id"] => id !== undefined);
|
||||
.filter((id): id is TSurveyElementChoice["id"] => id !== undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,195 +97,242 @@ export function ElementConditional({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the element renders for the first time
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{element.type === TSurveyElementTypeEnum.OpenText ? (
|
||||
<OpenTextQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceSingleQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceMultiQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.NPS ? (
|
||||
<NPSQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "number" ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.CTA ? (
|
||||
<CTAQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Rating ? (
|
||||
<RatingQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "number" ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Consent ? (
|
||||
<ConsentQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Date ? (
|
||||
<DateQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.PictureSelection ? (
|
||||
<PictureSelectionQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.FileUpload ? (
|
||||
<FileUploadQuestion
|
||||
key={element.id}
|
||||
surveyId={surveyId}
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
onFileUpload={onFileUpload}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Cal ? (
|
||||
<CalQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Matrix ? (
|
||||
<MatrixQuestion
|
||||
question={element}
|
||||
value={typeof value === "object" && !Array.isArray(value) ? value : {}}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Address ? (
|
||||
<AddressQuestion
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Ranking ? (
|
||||
<RankingQuestion
|
||||
question={element}
|
||||
value={Array.isArray(value) ? getResponseValueForRankingQuestion(value, element.choices) : []}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestion
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const isRecognizedType = Object.values(TSurveyElementTypeEnum).includes(element.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecognizedType) {
|
||||
console.warn(
|
||||
`[Formbricks] Unrecognized element type "${element.type}" for element with id "${element.id}". No component will be rendered.`
|
||||
);
|
||||
}
|
||||
}, [element.type, element.id, isRecognizedType]);
|
||||
|
||||
if (!isRecognizedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderElement = () => {
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return (
|
||||
<OpenTextQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return (
|
||||
<MultipleChoiceSingleQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return (
|
||||
<MultipleChoiceMultiQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return (
|
||||
<NPSQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "number" ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
return (
|
||||
<CTAQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return (
|
||||
<RatingQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "number" ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return (
|
||||
<ConsentQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
return (
|
||||
<DateQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return (
|
||||
<PictureSelectionQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
return (
|
||||
<FileUploadQuestion
|
||||
key={element.id}
|
||||
surveyId={surveyId}
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
onFileUpload={onFileUpload}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
return (
|
||||
<CalQuestion
|
||||
key={element.id}
|
||||
question={element}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
return (
|
||||
<MatrixQuestion
|
||||
question={element}
|
||||
value={typeof value === "object" && !Array.isArray(value) ? value : {}}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
return (
|
||||
<AddressQuestion
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return (
|
||||
<RankingQuestion
|
||||
question={element}
|
||||
value={Array.isArray(value) ? getResponseValueForRankingQuestion(value, element.choices) : []}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentElementId}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
return (
|
||||
<ContactInfoQuestion
|
||||
question={element}
|
||||
value={Array.isArray(value) ? value : undefined}
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
currentQuestionId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return <div ref={containerRef}>{renderElement()}</div>;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ 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, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurvey } from "@/lib/utils";
|
||||
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
|
||||
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
|
||||
|
||||
interface VariableStackEntry {
|
||||
@@ -58,7 +58,6 @@ export function Survey({
|
||||
languageCode,
|
||||
getSetIsError,
|
||||
getSetIsResponseSendingFinished,
|
||||
getSetQuestionId,
|
||||
getSetBlockId,
|
||||
getSetResponseData,
|
||||
responseCount,
|
||||
@@ -140,7 +139,7 @@ export function Survey({
|
||||
return null;
|
||||
}, [appUrl, environmentId, getSetIsError, getSetIsResponseSendingFinished, surveyState]);
|
||||
|
||||
const questions = useMemo(() => getElementsFromSurvey(localSurvey), [localSurvey]);
|
||||
const questions = useMemo(() => getElementsFromSurveyBlocks(localSurvey.blocks), [localSurvey.blocks]);
|
||||
|
||||
const originalQuestionRequiredStates = useMemo(() => {
|
||||
return questions.reduce<Record<string, boolean>>((acc, question) => {
|
||||
@@ -173,7 +172,7 @@ export function Survey({
|
||||
const [blockId, setBlockId] = useState(() => {
|
||||
if (startAtQuestionId) {
|
||||
// If starting at a specific question, find its parent block
|
||||
const startBlock = findBlockByElementId(localSurvey, startAtQuestionId);
|
||||
const startBlock = findBlockByElementId(localSurvey.blocks, startAtQuestionId);
|
||||
return startBlock?.id || localSurvey.blocks[0]?.id;
|
||||
} else if (localSurvey.welcomeCard.enabled) {
|
||||
return "start";
|
||||
@@ -312,18 +311,6 @@ export function Survey({
|
||||
}
|
||||
}, [getSetIsError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (getSetQuestionId) {
|
||||
getSetQuestionId((value: string) => {
|
||||
// Convert question ID to block ID
|
||||
const block = findBlockByElementId(survey, value);
|
||||
if (block) {
|
||||
setBlockId(block.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [getSetQuestionId, survey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (getSetBlockId) {
|
||||
getSetBlockId((value: string) => {
|
||||
@@ -770,7 +757,6 @@ export function Survey({
|
||||
Boolean(block) && (
|
||||
<BlockConditional
|
||||
key={block.id}
|
||||
// survey={localSurvey}
|
||||
surveyId={localSurvey.id}
|
||||
block={{
|
||||
...block,
|
||||
@@ -791,7 +777,6 @@ export function Survey({
|
||||
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
|
||||
languageCode={selectedLanguage}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentBlockId={blockId}
|
||||
isBackButtonHidden={localSurvey.isBackButtonHidden}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
dir={dir}
|
||||
|
||||
@@ -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, getElementsFromSurvey } from "@/lib/utils";
|
||||
import { calculateElementIdx, getElementsFromSurveyBlocks } from "@/lib/utils";
|
||||
import { Headline } from "./headline";
|
||||
import { Subheader } from "./subheader";
|
||||
|
||||
@@ -84,7 +84,7 @@ export function WelcomeCard({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const calculateTimeToComplete = () => {
|
||||
const questions = getElementsFromSurvey(survey);
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
let totalCards = questions.length;
|
||||
if (survey.endings.length > 0) totalCards += 1;
|
||||
let idx = calculateElementIdx(survey, 0, totalCards);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const LinkIcon = ({ className }: LinkIconProps) => {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}>
|
||||
|
||||
@@ -36,44 +36,58 @@ export function AddressQuestion({
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
const safeValue = useMemo(() => {
|
||||
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
|
||||
}, [value]);
|
||||
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: "addressLine1",
|
||||
...question.addressLine1,
|
||||
label: question.addressLine1.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "addressLine2",
|
||||
...question.addressLine2,
|
||||
label: question.addressLine2.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "city",
|
||||
...question.city,
|
||||
label: question.city.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
...question.state,
|
||||
label: question.state.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "zip",
|
||||
...question.zip,
|
||||
label: question.zip.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
...question.country,
|
||||
label: question.country.placeholder[languageCode],
|
||||
},
|
||||
];
|
||||
const fields = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "addressLine1",
|
||||
...question.addressLine1,
|
||||
label: question.addressLine1.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "addressLine2",
|
||||
...question.addressLine2,
|
||||
label: question.addressLine2.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "city",
|
||||
...question.city,
|
||||
label: question.city.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
...question.state,
|
||||
label: question.state.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "zip",
|
||||
...question.zip,
|
||||
label: question.zip.placeholder[languageCode],
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
...question.country,
|
||||
label: question.country.placeholder[languageCode],
|
||||
},
|
||||
],
|
||||
[
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
languageCode,
|
||||
]
|
||||
);
|
||||
|
||||
const handleChange = (fieldId: string, fieldValue: string) => {
|
||||
const newValue = fields.map((field) => {
|
||||
@@ -102,6 +116,25 @@ export function AddressQuestion({
|
||||
[question.id, autoFocusEnabled, currentQuestionId]
|
||||
);
|
||||
|
||||
const isFieldRequired = useCallback(
|
||||
(field: (typeof fields)[number]) => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if all fields are optional and the question is required, then the fields should be required
|
||||
if (
|
||||
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
|
||||
question.required
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[fields, question.required]
|
||||
);
|
||||
|
||||
return (
|
||||
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
|
||||
<div>
|
||||
@@ -118,32 +151,17 @@ export function AddressQuestion({
|
||||
|
||||
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
|
||||
{fields.map((field, index) => {
|
||||
const isFieldRequired = () => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if all fields are optional and the question is required, then the fields should be required
|
||||
if (
|
||||
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
|
||||
question.required
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
const isRequired = isFieldRequired(field);
|
||||
|
||||
return (
|
||||
field.show && (
|
||||
<div className="fb-space-y-1">
|
||||
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
|
||||
<div className="fb-space-y-1" key={field.id}>
|
||||
<Label htmlForId={field.id} text={isRequired ? `${field.label}*` : field.label} />
|
||||
<Input
|
||||
id={field.id}
|
||||
key={field.id}
|
||||
required={isFieldRequired()}
|
||||
required={isRequired}
|
||||
value={safeValue[index] || ""}
|
||||
type={field.id === "email" ? "email" : "text"}
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
handleChange(field.id, e.currentTarget.value);
|
||||
}}
|
||||
|
||||
@@ -35,10 +35,11 @@ export function CalQuestion({
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
const onSuccessfulBooking = useCallback(() => {
|
||||
setErrorMessage("");
|
||||
onChange({ [question.id]: "booked" });
|
||||
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedttc);
|
||||
}, [onChange, question.id, setTtc, startTime, ttc]);
|
||||
}, [onChange, question.id, setTtc, startTime, ttc, setErrorMessage]);
|
||||
|
||||
return (
|
||||
<form
|
||||
|
||||
@@ -96,7 +96,7 @@ export function ConsentQuestion({
|
||||
aria-labelledby={`${question.id}-label`}
|
||||
required={question.required}
|
||||
/>
|
||||
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
|
||||
<span className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
|
||||
{getLocalizedValue(question.label, languageCode)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CTAQuestion({
|
||||
<Headline
|
||||
headline={getLocalizedValue(question.headline, languageCode)}
|
||||
questionId={question.id}
|
||||
required={question.required}
|
||||
required={true}
|
||||
/>
|
||||
<Subheader
|
||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
||||
|
||||
@@ -127,10 +127,14 @@ export function DateQuestion({
|
||||
key={question.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required field
|
||||
if (question.required && !value) {
|
||||
setErrorMessage(t("errors.please_select_a_date"));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
}}
|
||||
@@ -189,6 +193,15 @@ export function DateQuestion({
|
||||
isOpen={datePickerOpen}
|
||||
onChange={(value) => {
|
||||
const date = value as Date;
|
||||
|
||||
if (!date) {
|
||||
if (question.required) {
|
||||
setErrorMessage(t("errors.please_select_a_date"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
setSelectedDate(date);
|
||||
|
||||
// Get the timezone offset in minutes and convert it to milliseconds
|
||||
|
||||
@@ -237,9 +237,8 @@ export function MultipleChoiceMultiQuestion({
|
||||
baseLabelClassName
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
// Accessibility: if spacebar was pressed pass this down to the checkbox
|
||||
if (e.key === " ") {
|
||||
if (otherSelected) return;
|
||||
e.preventDefault();
|
||||
document.getElementById(otherOption.id)?.click();
|
||||
}
|
||||
@@ -248,7 +247,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
<input
|
||||
type="checkbox"
|
||||
dir={dir}
|
||||
tabIndex={-1}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
id={otherOption.id}
|
||||
name={question.id}
|
||||
value={getLocalizedValue(otherOption.label, languageCode)}
|
||||
@@ -279,7 +278,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
<input
|
||||
ref={otherSpecify}
|
||||
dir={otherOptionInputDir}
|
||||
id={`${otherOption.id}-label`}
|
||||
id={`${otherOption.id}-specify`}
|
||||
maxLength={250}
|
||||
name={question.id}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
|
||||
@@ -180,9 +180,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
<label
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
className={cn(
|
||||
value === getLocalizedValue(otherOption.label, languageCode)
|
||||
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
|
||||
: "fb-border-border",
|
||||
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
|
||||
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -224,11 +222,11 @@ export function MultipleChoiceSingleQuestion({
|
||||
{otherSelected ? (
|
||||
<input
|
||||
ref={otherSpecify}
|
||||
id={`${otherOption.id}-label`}
|
||||
id={`${otherOption.id}-input`}
|
||||
dir={otherOptionInputDir}
|
||||
name={question.id}
|
||||
pattern=".*\S+.*"
|
||||
value={value}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
onChange({ [question.id]: e.currentTarget.value });
|
||||
}}
|
||||
|
||||
@@ -169,7 +169,7 @@ export function OpenTextQuestion({
|
||||
)}
|
||||
{question.inputType === "text" && question.charLimit?.max !== undefined && (
|
||||
<span
|
||||
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 font-semibold" : "text-neutral-400"}`}>
|
||||
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 fb-font-semibold" : "fb-text-neutral-400"}`}>
|
||||
{currentLength}/{question.charLimit?.max}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback, useMemo, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import type { TSurveyElementChoice, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
import { QuestionMedia } from "@/components/general/question-media";
|
||||
import { Subheader } from "@/components/general/subheader";
|
||||
@@ -55,7 +54,7 @@ export function RankingQuestion({
|
||||
const sortedItems = useMemo(() => {
|
||||
return localValue
|
||||
.map((id) => question.choices.find((c) => c.id === id))
|
||||
.filter((item): item is TSurveyQuestionChoice => item !== undefined);
|
||||
.filter((item): item is TSurveyElementChoice => item !== undefined);
|
||||
}, [localValue, question.choices]);
|
||||
|
||||
const unsortedItems = useMemo(() => {
|
||||
@@ -66,7 +65,7 @@ export function RankingQuestion({
|
||||
}, [question.choices, question.shuffleOption, localValue, sortedItems, shuffledChoicesIds]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: TSurveyQuestionChoice) => {
|
||||
(item: TSurveyElementChoice) => {
|
||||
const isAlreadySorted = localValue.includes(item.id);
|
||||
const newLocalValue = isAlreadySorted
|
||||
? localValue.filter((id) => id !== item.id)
|
||||
@@ -77,7 +76,7 @@ export function RankingQuestion({
|
||||
// Immediately update parent state with the new ranking
|
||||
const sortedLabels = newLocalValue
|
||||
.map((id) => question.choices.find((c) => c.id === id))
|
||||
.filter((item): item is TSurveyQuestionChoice => item !== undefined)
|
||||
.filter((item): item is TSurveyElementChoice => item !== undefined)
|
||||
.map((item) => getLocalizedValue(item.label, languageCode));
|
||||
onChange({ [question.id]: sortedLabels });
|
||||
|
||||
@@ -101,7 +100,7 @@ export function RankingQuestion({
|
||||
// Immediately update parent state with the new ranking
|
||||
const sortedLabels = newLocalValue
|
||||
.map((id) => question.choices.find((c) => c.id === id))
|
||||
.filter((item): item is TSurveyQuestionChoice => item !== undefined)
|
||||
.filter((item): item is TSurveyElementChoice => item !== undefined)
|
||||
.map((item) => getLocalizedValue(item.label, languageCode));
|
||||
onChange({ [question.id]: sortedLabels });
|
||||
|
||||
|
||||
@@ -45,9 +45,11 @@ export function StackedCardsContainer({
|
||||
|
||||
const blockIdxTemp = useMemo(() => {
|
||||
if (currentBlockId === "start") return survey.welcomeCard.enabled ? -1 : 0;
|
||||
|
||||
if (!survey.blocks.map((block) => block.id).includes(currentBlockId)) {
|
||||
return survey.blocks.length;
|
||||
}
|
||||
|
||||
return survey.blocks.findIndex((block) => block.id === currentBlockId);
|
||||
}, [currentBlockId, survey]);
|
||||
|
||||
@@ -132,7 +134,7 @@ export function StackedCardsContainer({
|
||||
// Reset block progress, when card arrangement changes
|
||||
useEffect(() => {
|
||||
if (shouldResetBlockId) {
|
||||
setBlockId(survey.welcomeCard.enabled ? "start" : survey.blocks[0]?.id);
|
||||
setBlockId(survey.welcomeCard.enabled ? "start" : survey.blocks[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when cardArrangement changes
|
||||
}, [cardArrangement]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/survey
|
||||
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getElementsFromSurvey } from "./utils";
|
||||
import { getElementsFromSurveyBlocks } from "./utils";
|
||||
|
||||
const getVariableValue = (
|
||||
variables: TSurveyVariable[],
|
||||
@@ -89,7 +89,7 @@ const getLeftOperandValue = (
|
||||
) => {
|
||||
switch (leftOperand.type) {
|
||||
case "element":
|
||||
const questions = getElementsFromSurvey(localSurvey);
|
||||
const questions = getElementsFromSurveyBlocks(localSurvey.blocks);
|
||||
const currentQuestion = questions.find((q) => q.id === leftOperand.value);
|
||||
if (!currentQuestion) return undefined;
|
||||
|
||||
@@ -223,7 +223,7 @@ const evaluateSingleCondition = (
|
||||
|
||||
let leftField: TSurveyElement | TSurveyVariable | string;
|
||||
|
||||
const questions = getElementsFromSurvey(localSurvey);
|
||||
const questions = getElementsFromSurveyBlocks(localSurvey.blocks);
|
||||
if (condition.leftOperand?.type === "element") {
|
||||
leftField = questions.find((q) => q.id === condition.leftOperand?.value) ?? "";
|
||||
} else if (condition.leftOperand?.type === "variable") {
|
||||
|
||||
@@ -2,11 +2,11 @@ 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 type { TSurveyLanguage } from "../../../types/surveys/types";
|
||||
import {
|
||||
findBlockByElementId,
|
||||
getDefaultLanguageCode,
|
||||
getElementsFromSurvey,
|
||||
getElementsFromSurveyBlocks,
|
||||
getMimeType,
|
||||
getShuffledChoicesIds,
|
||||
getShuffledRowIndices,
|
||||
@@ -140,12 +140,12 @@ describe("getShuffledChoicesIds", () => {
|
||||
mockGetRandomValues.mockReset();
|
||||
});
|
||||
|
||||
const choicesBase: TSurveyQuestionChoice[] = [
|
||||
const choicesBase = [
|
||||
{ id: "c1", label: { en: "Choice 1" } },
|
||||
{ id: "c2", label: { en: "Choice 2" } },
|
||||
{ id: "c3", label: { en: "Choice 3" } },
|
||||
];
|
||||
const choicesWithOther: TSurveyQuestionChoice[] = [...choicesBase, { id: "other", label: { en: "Other" } }];
|
||||
const choicesWithOther = [...choicesBase, { id: "other", label: { en: "Other" } }];
|
||||
|
||||
test('should return unshuffled for "none"', () => {
|
||||
expect(getShuffledChoicesIds(choicesBase, "none")).toEqual(["c1", "c2", "c3"]);
|
||||
@@ -225,7 +225,7 @@ describe("getQuestionsFromSurvey", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const questions = getElementsFromSurvey(survey);
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
expect(questions).toHaveLength(3);
|
||||
expect(questions[0].id).toBe("q1");
|
||||
expect(questions[1].id).toBe("q2");
|
||||
@@ -238,7 +238,7 @@ describe("getQuestionsFromSurvey", () => {
|
||||
blocks: [],
|
||||
} as TJsEnvironmentStateSurvey;
|
||||
|
||||
expect(getElementsFromSurvey(survey)).toEqual([]);
|
||||
expect(getElementsFromSurveyBlocks(survey.blocks)).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle blocks with no elements", () => {
|
||||
@@ -263,7 +263,7 @@ describe("getQuestionsFromSurvey", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const questions = getElementsFromSurvey(survey);
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
expect(questions).toHaveLength(1);
|
||||
expect(questions[0].id).toBe("q1");
|
||||
});
|
||||
@@ -313,17 +313,17 @@ describe("findBlockByElementId", () => {
|
||||
};
|
||||
|
||||
test("should find block containing the element", () => {
|
||||
const block = findBlockByElementId(survey, "q1");
|
||||
const block = findBlockByElementId(survey.blocks, "q1");
|
||||
expect(block).toBeDefined();
|
||||
expect(block?.id).toBe("block1");
|
||||
|
||||
const block2 = findBlockByElementId(survey, "q3");
|
||||
const block2 = findBlockByElementId(survey.blocks, "q3");
|
||||
expect(block2).toBeDefined();
|
||||
expect(block2?.id).toBe("block2");
|
||||
});
|
||||
|
||||
test("should return undefined for non-existent element", () => {
|
||||
const block = findBlockByElementId(survey, "nonexistent");
|
||||
const block = findBlockByElementId(survey.blocks, "nonexistent");
|
||||
expect(block).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +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 { 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 { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { type TSurveyElement, TSurveyElementChoice } from "@formbricks/types/surveys/elements";
|
||||
import { type TShuffleOption } from "@formbricks/types/surveys/types";
|
||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||
|
||||
export const cn = (...classes: string[]) => {
|
||||
@@ -41,7 +41,7 @@ export const getShuffledRowIndices = (n: number, shuffleOption: TShuffleOption):
|
||||
};
|
||||
|
||||
export const getShuffledChoicesIds = (
|
||||
choices: TSurveyQuestionChoice[],
|
||||
choices: TSurveyElementChoice[],
|
||||
shuffleOption: TShuffleOption
|
||||
): string[] => {
|
||||
const otherOption = choices.find((choice) => {
|
||||
@@ -79,10 +79,10 @@ export const calculateElementIdx = (
|
||||
currentQustionIdx: number,
|
||||
totalCards: number
|
||||
): number => {
|
||||
const questions = getElementsFromSurvey(survey);
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
const currentQuestion = questions[currentQustionIdx];
|
||||
const middleIdx = Math.floor(totalCards / 2);
|
||||
const possibleNextBlockIds = getPossibleNextBlocks(survey, currentQuestion);
|
||||
const possibleNextBlockIds = getPossibleNextBlocks(survey.blocks, currentQuestion);
|
||||
const endingCardIds = survey.endings.map((ending) => ending.id);
|
||||
|
||||
// Convert block IDs to element IDs (get first element of each block)
|
||||
@@ -106,9 +106,9 @@ export const calculateElementIdx = (
|
||||
return elementIdx;
|
||||
};
|
||||
|
||||
const getPossibleNextBlocks = (survey: TJsEnvironmentStateSurvey, element: TSurveyElement): string[] => {
|
||||
const getPossibleNextBlocks = (blocks: TSurveyBlock[], element: TSurveyElement): string[] => {
|
||||
// In the blocks model, logic is stored at the block level
|
||||
const parentBlock = findBlockByElementId(survey, element.id);
|
||||
const parentBlock = findBlockByElementId(blocks, element.id);
|
||||
if (!parentBlock?.logic) return [];
|
||||
|
||||
const possibleBlockIds: string[] = [];
|
||||
@@ -197,7 +197,7 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo
|
||||
}
|
||||
}
|
||||
|
||||
const questions = getElementsFromSurvey(survey);
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
for (const question of questions) {
|
||||
const questionHeadline = question.headline[languageCode];
|
||||
|
||||
@@ -212,11 +212,11 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo
|
||||
|
||||
/**
|
||||
* Derives a flat array of elements from the survey's blocks structure.
|
||||
* @param survey The survey object with blocks
|
||||
* @param blocks The blocks array
|
||||
* @returns An array of TSurveyElement (pure elements without block-level properties)
|
||||
*/
|
||||
export const getElementsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] =>
|
||||
survey.blocks.flatMap((block) => block.elements);
|
||||
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
|
||||
blocks.flatMap((block) => block.elements);
|
||||
|
||||
/**
|
||||
* Finds the parent block that contains the specified element ID.
|
||||
@@ -225,8 +225,8 @@ export const getElementsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurve
|
||||
* @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));
|
||||
export const findBlockByElementId = (blocks: TSurveyBlock[], elementId: string) =>
|
||||
blocks.find((block) => block.elements.some((e) => e.id === elementId));
|
||||
|
||||
/**
|
||||
* Converts a block ID to the first element ID in that block.
|
||||
@@ -242,39 +242,3 @@ export const getFirstElementIdInBlock = (
|
||||
const block = survey.blocks.find((b) => b.id === blockId);
|
||||
return block?.elements[0]?.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a block by its ID.
|
||||
* @param survey The survey object
|
||||
* @param blockId The block ID to find
|
||||
* @returns The block or undefined if not found
|
||||
*/
|
||||
export const getBlockById = (survey: TJsEnvironmentStateSurvey, blockId: string) => {
|
||||
return survey.blocks.find((b) => b.id === blockId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the next block ID after the current block.
|
||||
* @param survey The survey object
|
||||
* @param currentBlockId The current block ID
|
||||
* @returns The next block ID or undefined if current block is last
|
||||
*/
|
||||
export const getNextBlockId = (
|
||||
survey: TJsEnvironmentStateSurvey,
|
||||
currentBlockId: string
|
||||
): string | undefined => {
|
||||
const currentIndex = survey.blocks.findIndex((b) => b.id === currentBlockId);
|
||||
if (currentIndex === -1) return undefined;
|
||||
return survey.blocks[currentIndex + 1]?.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets all element IDs in a block.
|
||||
* @param survey The survey object
|
||||
* @param blockId The block ID
|
||||
* @returns Array of element IDs in the block
|
||||
*/
|
||||
export const getElementIdsInBlock = (survey: TJsEnvironmentStateSurvey, blockId: string): string[] => {
|
||||
const block = getBlockById(survey, blockId);
|
||||
return block?.elements.map((e) => e.id) ?? [];
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface SurveyBaseProps {
|
||||
isBrandingEnabled: boolean;
|
||||
getSetIsError?: (getSetError: (value: boolean) => void) => void;
|
||||
getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
|
||||
getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void;
|
||||
getSetBlockId?: (getSetBlockId: (value: string) => void) => void;
|
||||
getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void;
|
||||
onDisplay?: () => Promise<void>;
|
||||
|
||||
@@ -126,6 +126,8 @@ export const ZSurveyElementChoice = z.object({
|
||||
label: ZI18nString,
|
||||
});
|
||||
|
||||
export type TSurveyElementChoice = z.infer<typeof ZSurveyElementChoice>;
|
||||
|
||||
export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]);
|
||||
export type TShuffleOption = z.infer<typeof ZShuffleOption>;
|
||||
|
||||
|
||||
@@ -286,10 +286,6 @@ export const ZActionObjective = z.enum(["calculate", "requireAnswer", "jumpToQue
|
||||
export type TDynamicLogicField = z.infer<typeof ZDynamicLogicField>;
|
||||
export type TActionObjective = z.infer<typeof ZActionObjective>;
|
||||
|
||||
// Actions
|
||||
export const ZActionVariableValueType = z.union([z.literal("static"), ZDynamicLogicField]);
|
||||
export type TActionVariableValueType = z.infer<typeof ZActionVariableValueType>;
|
||||
|
||||
const ZActionBase = z.object({
|
||||
id: ZId,
|
||||
objective: ZActionObjective,
|
||||
@@ -1212,7 +1208,10 @@ export const ZSurvey = z
|
||||
// Validate block button labels
|
||||
const defaultLanguageCode = "default";
|
||||
|
||||
if (block.buttonLabel && block.buttonLabel[defaultLanguageCode].trim() !== "") {
|
||||
if (
|
||||
block.buttonLabel?.[defaultLanguageCode] &&
|
||||
block.buttonLabel[defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
// Validate button label for all enabled languages
|
||||
const enabledLanguages = languages.filter((lang) => lang.enabled);
|
||||
const languageCodes = enabledLanguages.map((lang) =>
|
||||
@@ -1237,7 +1236,10 @@ export const ZSurvey = z
|
||||
}
|
||||
}
|
||||
|
||||
if (block.backButtonLabel && block.backButtonLabel[defaultLanguageCode].trim() !== "") {
|
||||
if (
|
||||
block.backButtonLabel?.[defaultLanguageCode] &&
|
||||
block.backButtonLabel[defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
// Validate back button label for all enabled languages
|
||||
const enabledLanguages = languages.filter((lang) => lang.enabled);
|
||||
const languageCodes = enabledLanguages.map((lang) =>
|
||||
|
||||
Reference in New Issue
Block a user